mirror of
https://github.com/langgenius/dify.git
synced 2026-01-01 03:57:23 +00:00
Compare commits
6 Commits
feat/step-
...
refactor-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccc4fb3ae0 | ||
|
|
142f650d73 | ||
|
|
78d47fa28f | ||
|
|
ab3a933e11 | ||
|
|
99117314bf | ||
|
|
2088660f55 |
@@ -0,0 +1,388 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { TFunction } from 'i18next'
|
||||
import ContentSection from './content-section'
|
||||
import type { WorkflowProcess } from '@/app/components/base/chat/types'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
|
||||
// ============================================================================
|
||||
// Mock Setup
|
||||
// ============================================================================
|
||||
|
||||
jest.mock('@/app/components/base/chat/chat/answer/workflow-process', () => ({
|
||||
__esModule: true,
|
||||
default: ({ data }: { data: WorkflowProcess }) => (
|
||||
<div data-testid="workflow-process">{data.resultText}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/base/markdown', () => ({
|
||||
Markdown: ({ content }: { content: string }) => (
|
||||
<div data-testid="markdown">{content}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('./result-tab', () => ({
|
||||
__esModule: true,
|
||||
default: ({ currentTab }: { currentTab: string }) => (
|
||||
<div data-testid="result-tab">{currentTab}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test Utilities
|
||||
// ============================================================================
|
||||
|
||||
const mockT = ((key: string) => key) as TFunction
|
||||
|
||||
/**
|
||||
* Creates base props with sensible defaults for ContentSection testing.
|
||||
*/
|
||||
const createBaseProps = (overrides?: Partial<Parameters<typeof ContentSection>[0]>) => ({
|
||||
depth: 1,
|
||||
isError: false,
|
||||
content: 'test content',
|
||||
currentTab: 'DETAIL' as const,
|
||||
onSwitchTab: jest.fn(),
|
||||
showResultTabs: false,
|
||||
t: mockT,
|
||||
siteInfo: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createWorkflowData = (overrides?: Partial<WorkflowProcess>): WorkflowProcess => ({
|
||||
status: 'succeeded',
|
||||
tracing: [],
|
||||
expand: true,
|
||||
resultText: 'workflow result',
|
||||
...overrides,
|
||||
} as WorkflowProcess)
|
||||
|
||||
const createSiteInfo = (overrides?: Partial<SiteInfo>): SiteInfo => ({
|
||||
title: 'Test Site',
|
||||
icon: '',
|
||||
icon_background: '',
|
||||
description: '',
|
||||
default_language: 'en',
|
||||
prompt_public: false,
|
||||
copyright: '',
|
||||
privacy_policy: '',
|
||||
custom_disclaimer: '',
|
||||
show_workflow_steps: true,
|
||||
use_icon_as_answer_icon: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Test Suite
|
||||
// ============================================================================
|
||||
|
||||
describe('ContentSection', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Basic Rendering Tests
|
||||
// Tests for basic component rendering scenarios
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Basic Rendering', () => {
|
||||
it('should render markdown content when no workflow data', () => {
|
||||
render(<ContentSection {...createBaseProps({ content: 'Hello World' })} />)
|
||||
expect(screen.getByTestId('markdown')).toHaveTextContent('Hello World')
|
||||
})
|
||||
|
||||
it('should not render markdown when content is not a string', () => {
|
||||
render(<ContentSection {...createBaseProps({ content: { data: 'object' } })} />)
|
||||
expect(screen.queryByTestId('markdown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply rounded styling when not in side panel', () => {
|
||||
const { container } = render(<ContentSection {...createBaseProps()} />)
|
||||
expect(container.firstChild).toHaveClass('rounded-2xl')
|
||||
})
|
||||
|
||||
it('should not apply rounded styling when in side panel', () => {
|
||||
const { container } = render(<ContentSection {...createBaseProps({ inSidePanel: true })} />)
|
||||
expect(container.firstChild).not.toHaveClass('rounded-2xl')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Task ID Display Tests
|
||||
// Tests for task ID rendering in different contexts
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Task ID Display', () => {
|
||||
it('should show task ID without depth suffix at depth 1', () => {
|
||||
render(
|
||||
<ContentSection
|
||||
{...createBaseProps({
|
||||
taskId: 'task-123',
|
||||
depth: 1,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('task-123')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show task ID with depth suffix at depth > 1', () => {
|
||||
render(
|
||||
<ContentSection
|
||||
{...createBaseProps({
|
||||
taskId: 'task-123',
|
||||
depth: 3,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('task-123-2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show execution label with task ID', () => {
|
||||
render(
|
||||
<ContentSection
|
||||
{...createBaseProps({
|
||||
taskId: 'task-abc',
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('share.generation.execution')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Error State Tests
|
||||
// Tests for error state display
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Error State', () => {
|
||||
it('should show error message when isError is true', () => {
|
||||
render(<ContentSection {...createBaseProps({ isError: true })} />)
|
||||
expect(screen.getByText('share.generation.batchFailed.outputPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show markdown content when isError is true', () => {
|
||||
render(<ContentSection {...createBaseProps({ isError: true, content: 'should not show' })} />)
|
||||
expect(screen.queryByTestId('markdown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply error styling to task ID when isError is true', () => {
|
||||
render(
|
||||
<ContentSection
|
||||
{...createBaseProps({
|
||||
taskId: 'error-task',
|
||||
isError: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const executionText = screen.getByText('share.generation.execution').parentElement
|
||||
expect(executionText).toHaveClass('text-text-destructive')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Workflow Process Tests
|
||||
// Tests for workflow-specific rendering
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Workflow Process', () => {
|
||||
it('should render WorkflowProcessItem when workflowProcessData and siteInfo are present', () => {
|
||||
render(
|
||||
<ContentSection
|
||||
{...createBaseProps({
|
||||
workflowProcessData: createWorkflowData(),
|
||||
siteInfo: createSiteInfo(),
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('workflow-process')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render WorkflowProcessItem when siteInfo is null', () => {
|
||||
render(
|
||||
<ContentSection
|
||||
{...createBaseProps({
|
||||
workflowProcessData: createWorkflowData(),
|
||||
siteInfo: null,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByTestId('workflow-process')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show task ID within workflow section', () => {
|
||||
render(
|
||||
<ContentSection
|
||||
{...createBaseProps({
|
||||
workflowProcessData: createWorkflowData(),
|
||||
taskId: 'wf-task-123',
|
||||
siteInfo: createSiteInfo(),
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('wf-task-123')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render ResultTab when isError is true', () => {
|
||||
render(
|
||||
<ContentSection
|
||||
{...createBaseProps({
|
||||
workflowProcessData: createWorkflowData(),
|
||||
siteInfo: createSiteInfo(),
|
||||
isError: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByTestId('result-tab')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ResultTab when not error', () => {
|
||||
render(
|
||||
<ContentSection
|
||||
{...createBaseProps({
|
||||
workflowProcessData: createWorkflowData(),
|
||||
siteInfo: createSiteInfo(),
|
||||
isError: false,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('result-tab')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Result Tabs Tests
|
||||
// Tests for the result/detail tab switching
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Result Tabs', () => {
|
||||
it('should show tabs when showResultTabs is true', () => {
|
||||
render(
|
||||
<ContentSection
|
||||
{...createBaseProps({
|
||||
workflowProcessData: createWorkflowData(),
|
||||
siteInfo: createSiteInfo(),
|
||||
showResultTabs: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('runLog.result')).toBeInTheDocument()
|
||||
expect(screen.getByText('runLog.detail')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show tabs when showResultTabs is false', () => {
|
||||
render(
|
||||
<ContentSection
|
||||
{...createBaseProps({
|
||||
workflowProcessData: createWorkflowData(),
|
||||
siteInfo: createSiteInfo(),
|
||||
showResultTabs: false,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByText('runLog.result')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('runLog.detail')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSwitchTab when RESULT tab is clicked', () => {
|
||||
const onSwitchTab = jest.fn()
|
||||
render(
|
||||
<ContentSection
|
||||
{...createBaseProps({
|
||||
workflowProcessData: createWorkflowData(),
|
||||
siteInfo: createSiteInfo(),
|
||||
showResultTabs: true,
|
||||
onSwitchTab,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('runLog.result'))
|
||||
expect(onSwitchTab).toHaveBeenCalledWith('RESULT')
|
||||
})
|
||||
|
||||
it('should call onSwitchTab when DETAIL tab is clicked', () => {
|
||||
const onSwitchTab = jest.fn()
|
||||
render(
|
||||
<ContentSection
|
||||
{...createBaseProps({
|
||||
workflowProcessData: createWorkflowData(),
|
||||
siteInfo: createSiteInfo(),
|
||||
showResultTabs: true,
|
||||
onSwitchTab,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('runLog.detail'))
|
||||
expect(onSwitchTab).toHaveBeenCalledWith('DETAIL')
|
||||
})
|
||||
|
||||
it('should highlight active tab', () => {
|
||||
render(
|
||||
<ContentSection
|
||||
{...createBaseProps({
|
||||
workflowProcessData: createWorkflowData(),
|
||||
siteInfo: createSiteInfo(),
|
||||
showResultTabs: true,
|
||||
currentTab: 'RESULT',
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
const resultTab = screen.getByText('runLog.result')
|
||||
expect(resultTab).toHaveClass('text-text-primary')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Boundary Conditions Tests
|
||||
// Tests for edge cases and boundary values
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Boundary Conditions', () => {
|
||||
it('should handle empty string content', () => {
|
||||
render(<ContentSection {...createBaseProps({ content: '' })} />)
|
||||
expect(screen.getByTestId('markdown')).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should handle null siteInfo', () => {
|
||||
render(
|
||||
<ContentSection
|
||||
{...createBaseProps({
|
||||
workflowProcessData: createWorkflowData(),
|
||||
siteInfo: null,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
// Should not crash and WorkflowProcessItem should not render
|
||||
expect(screen.queryByTestId('workflow-process')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined workflowProcessData', () => {
|
||||
render(<ContentSection {...createBaseProps({ workflowProcessData: undefined })} />)
|
||||
expect(screen.queryByTestId('workflow-process')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle show_workflow_steps false in siteInfo', () => {
|
||||
render(
|
||||
<ContentSection
|
||||
{...createBaseProps({
|
||||
workflowProcessData: createWorkflowData(),
|
||||
siteInfo: createSiteInfo({ show_workflow_steps: false }),
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('workflow-process')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle hideProcessDetail prop', () => {
|
||||
render(
|
||||
<ContentSection
|
||||
{...createBaseProps({
|
||||
workflowProcessData: createWorkflowData(),
|
||||
siteInfo: createSiteInfo(),
|
||||
hideProcessDetail: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
// Component should still render
|
||||
expect(screen.getByTestId('workflow-process')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
112
web/app/components/app/text-generate/item/content-section.tsx
Normal file
112
web/app/components/app/text-generate/item/content-section.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { FC } from 'react'
|
||||
import type { TFunction } from 'i18next'
|
||||
import { RiPlayList2Line } from '@remixicon/react'
|
||||
import WorkflowProcessItem from '@/app/components/base/chat/chat/answer/workflow-process'
|
||||
import type { WorkflowProcess } from '@/app/components/base/chat/types'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import cn from '@/utils/classnames'
|
||||
import ResultTab from './result-tab'
|
||||
|
||||
type ContentSectionProps = {
|
||||
workflowProcessData?: WorkflowProcess
|
||||
taskId?: string
|
||||
depth: number
|
||||
isError: boolean
|
||||
content: any
|
||||
hideProcessDetail?: boolean
|
||||
siteInfo: SiteInfo | null
|
||||
currentTab: 'DETAIL' | 'RESULT'
|
||||
onSwitchTab: (tab: 'DETAIL' | 'RESULT') => void
|
||||
showResultTabs: boolean
|
||||
t: TFunction
|
||||
inSidePanel?: boolean
|
||||
}
|
||||
|
||||
const ContentSection: FC<ContentSectionProps> = ({
|
||||
workflowProcessData,
|
||||
taskId,
|
||||
depth,
|
||||
isError,
|
||||
content,
|
||||
hideProcessDetail,
|
||||
siteInfo,
|
||||
currentTab,
|
||||
onSwitchTab,
|
||||
showResultTabs,
|
||||
t,
|
||||
inSidePanel,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(
|
||||
'relative',
|
||||
!inSidePanel && 'rounded-2xl border-t border-divider-subtle bg-chat-bubble-bg',
|
||||
)}>
|
||||
{workflowProcessData && (
|
||||
<>
|
||||
<div className={cn(
|
||||
'p-3',
|
||||
showResultTabs && 'border-b border-divider-subtle',
|
||||
)}>
|
||||
{taskId && (
|
||||
<div className={cn('system-2xs-medium-uppercase mb-2 flex items-center text-text-accent-secondary', isError && 'text-text-destructive')}>
|
||||
<RiPlayList2Line className='mr-1 h-3 w-3' />
|
||||
<span>{t('share.generation.execution')}</span>
|
||||
<span className='px-1'>·</span>
|
||||
<span>{taskId}</span>
|
||||
</div>
|
||||
)}
|
||||
{siteInfo && workflowProcessData && (
|
||||
<WorkflowProcessItem
|
||||
data={workflowProcessData}
|
||||
expand={workflowProcessData.expand}
|
||||
hideProcessDetail={hideProcessDetail}
|
||||
hideInfo={hideProcessDetail}
|
||||
readonly={!siteInfo.show_workflow_steps}
|
||||
/>
|
||||
)}
|
||||
{showResultTabs && (
|
||||
<div className='flex items-center space-x-6 px-1'>
|
||||
<div
|
||||
className={cn(
|
||||
'system-sm-semibold-uppercase cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary',
|
||||
currentTab === 'RESULT' && 'border-util-colors-blue-brand-blue-brand-600 text-text-primary',
|
||||
)}
|
||||
onClick={() => onSwitchTab('RESULT')}
|
||||
>{t('runLog.result')}</div>
|
||||
<div
|
||||
className={cn(
|
||||
'system-sm-semibold-uppercase cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary',
|
||||
currentTab === 'DETAIL' && 'border-util-colors-blue-brand-blue-brand-600 text-text-primary',
|
||||
)}
|
||||
onClick={() => onSwitchTab('DETAIL')}
|
||||
>{t('runLog.detail')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isError && (
|
||||
<ResultTab data={workflowProcessData} content={content} currentTab={currentTab} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!workflowProcessData && taskId && (
|
||||
<div className={cn('system-2xs-medium-uppercase sticky left-0 top-0 flex w-full items-center rounded-t-2xl bg-components-actionbar-bg p-4 pb-3 text-text-accent-secondary', isError && 'text-text-destructive')}>
|
||||
<RiPlayList2Line className='mr-1 h-3 w-3' />
|
||||
<span>{t('share.generation.execution')}</span>
|
||||
<span className='px-1'>·</span>
|
||||
<span>{`${taskId}${depth > 1 ? `-${depth - 1}` : ''}`}</span>
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<div className='body-lg-regular p-4 pt-0 text-text-quaternary'>{t('share.generation.batchFailed.outputPlaceholder')}</div>
|
||||
)}
|
||||
{!workflowProcessData && !isError && (typeof content === 'string') && (
|
||||
<div className={cn('p-4', taskId && 'pt-0')}>
|
||||
<Markdown content={content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContentSection
|
||||
411
web/app/components/app/text-generate/item/hooks.spec.ts
Normal file
411
web/app/components/app/text-generate/item/hooks.spec.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useMoreLikeThisState, useWorkflowTabs } from './hooks'
|
||||
import type { WorkflowProcess } from '@/app/components/base/chat/types'
|
||||
|
||||
// ============================================================================
|
||||
// useMoreLikeThisState Tests
|
||||
// ============================================================================
|
||||
describe('useMoreLikeThisState', () => {
|
||||
// --------------------------------------------------------------------------
|
||||
// Initial State Tests
|
||||
// Tests verifying the hook's initial state values
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Initial State', () => {
|
||||
it('should initialize with empty completionRes', () => {
|
||||
const { result } = renderHook(() => useMoreLikeThisState({}))
|
||||
expect(result.current.completionRes).toBe('')
|
||||
})
|
||||
|
||||
it('should initialize with null childMessageId', () => {
|
||||
const { result } = renderHook(() => useMoreLikeThisState({}))
|
||||
expect(result.current.childMessageId).toBeNull()
|
||||
})
|
||||
|
||||
it('should initialize with null rating in childFeedback', () => {
|
||||
const { result } = renderHook(() => useMoreLikeThisState({}))
|
||||
expect(result.current.childFeedback).toEqual({ rating: null })
|
||||
})
|
||||
|
||||
it('should initialize isQuerying as false', () => {
|
||||
const { result } = renderHook(() => useMoreLikeThisState({}))
|
||||
expect(result.current.isQuerying).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// State Setter Tests
|
||||
// Tests for state update functions
|
||||
// --------------------------------------------------------------------------
|
||||
describe('State Setters', () => {
|
||||
it('should update completionRes when setCompletionRes is called', () => {
|
||||
const { result } = renderHook(() => useMoreLikeThisState({}))
|
||||
|
||||
act(() => {
|
||||
result.current.setCompletionRes('new response')
|
||||
})
|
||||
|
||||
expect(result.current.completionRes).toBe('new response')
|
||||
})
|
||||
|
||||
it('should update childMessageId when setChildMessageId is called', () => {
|
||||
const { result } = renderHook(() => useMoreLikeThisState({}))
|
||||
|
||||
act(() => {
|
||||
result.current.setChildMessageId('child-123')
|
||||
})
|
||||
|
||||
expect(result.current.childMessageId).toBe('child-123')
|
||||
})
|
||||
|
||||
it('should update childFeedback when setChildFeedback is called', () => {
|
||||
const { result } = renderHook(() => useMoreLikeThisState({}))
|
||||
|
||||
act(() => {
|
||||
result.current.setChildFeedback({ rating: 'like' })
|
||||
})
|
||||
|
||||
expect(result.current.childFeedback).toEqual({ rating: 'like' })
|
||||
})
|
||||
|
||||
it('should set isQuerying to true when startQuerying is called', () => {
|
||||
const { result } = renderHook(() => useMoreLikeThisState({}))
|
||||
|
||||
act(() => {
|
||||
result.current.startQuerying()
|
||||
})
|
||||
|
||||
expect(result.current.isQuerying).toBe(true)
|
||||
})
|
||||
|
||||
it('should set isQuerying to false when stopQuerying is called', () => {
|
||||
const { result } = renderHook(() => useMoreLikeThisState({}))
|
||||
|
||||
act(() => {
|
||||
result.current.startQuerying()
|
||||
})
|
||||
expect(result.current.isQuerying).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.stopQuerying()
|
||||
})
|
||||
expect(result.current.isQuerying).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// controlClearMoreLikeThis Effect Tests
|
||||
// Tests for the clear effect triggered by controlClearMoreLikeThis
|
||||
// --------------------------------------------------------------------------
|
||||
describe('controlClearMoreLikeThis Effect', () => {
|
||||
it('should clear childMessageId when controlClearMoreLikeThis changes to truthy value', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ controlClearMoreLikeThis }: { controlClearMoreLikeThis?: number }) =>
|
||||
useMoreLikeThisState({ controlClearMoreLikeThis }),
|
||||
{ initialProps: { controlClearMoreLikeThis: undefined as number | undefined } },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setChildMessageId('child-to-clear')
|
||||
result.current.setCompletionRes('response-to-clear')
|
||||
})
|
||||
|
||||
expect(result.current.childMessageId).toBe('child-to-clear')
|
||||
expect(result.current.completionRes).toBe('response-to-clear')
|
||||
|
||||
rerender({ controlClearMoreLikeThis: 1 })
|
||||
|
||||
expect(result.current.childMessageId).toBeNull()
|
||||
expect(result.current.completionRes).toBe('')
|
||||
})
|
||||
|
||||
it('should not clear state when controlClearMoreLikeThis is 0', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ controlClearMoreLikeThis }: { controlClearMoreLikeThis?: number }) =>
|
||||
useMoreLikeThisState({ controlClearMoreLikeThis }),
|
||||
{ initialProps: { controlClearMoreLikeThis: undefined as number | undefined } },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setChildMessageId('keep-this')
|
||||
result.current.setCompletionRes('keep-response')
|
||||
})
|
||||
|
||||
rerender({ controlClearMoreLikeThis: 0 })
|
||||
|
||||
expect(result.current.childMessageId).toBe('keep-this')
|
||||
expect(result.current.completionRes).toBe('keep-response')
|
||||
})
|
||||
|
||||
it('should clear state when controlClearMoreLikeThis increments', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ controlClearMoreLikeThis }) => useMoreLikeThisState({ controlClearMoreLikeThis }),
|
||||
{ initialProps: { controlClearMoreLikeThis: 1 } },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setChildMessageId('will-clear')
|
||||
})
|
||||
|
||||
rerender({ controlClearMoreLikeThis: 2 })
|
||||
|
||||
expect(result.current.childMessageId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// isLoading Effect Tests
|
||||
// Tests for the effect triggered by isLoading changes
|
||||
// --------------------------------------------------------------------------
|
||||
describe('isLoading Effect', () => {
|
||||
it('should clear childMessageId when isLoading becomes true', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ isLoading }) => useMoreLikeThisState({ isLoading }),
|
||||
{ initialProps: { isLoading: false } },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setChildMessageId('child-during-load')
|
||||
})
|
||||
|
||||
expect(result.current.childMessageId).toBe('child-during-load')
|
||||
|
||||
rerender({ isLoading: true })
|
||||
|
||||
expect(result.current.childMessageId).toBeNull()
|
||||
})
|
||||
|
||||
it('should not clear childMessageId when isLoading is false', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ isLoading }) => useMoreLikeThisState({ isLoading }),
|
||||
{ initialProps: { isLoading: true } },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setChildMessageId('keep-child')
|
||||
})
|
||||
|
||||
rerender({ isLoading: false })
|
||||
|
||||
// childMessageId was already cleared when isLoading was true initially
|
||||
// Set it again after isLoading is false
|
||||
act(() => {
|
||||
result.current.setChildMessageId('keep-child-2')
|
||||
})
|
||||
|
||||
expect(result.current.childMessageId).toBe('keep-child-2')
|
||||
})
|
||||
|
||||
it('should not affect completionRes when isLoading changes', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ isLoading }) => useMoreLikeThisState({ isLoading }),
|
||||
{ initialProps: { isLoading: false } },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setCompletionRes('my response')
|
||||
})
|
||||
|
||||
rerender({ isLoading: true })
|
||||
|
||||
expect(result.current.completionRes).toBe('my response')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Boundary Conditions Tests
|
||||
// Tests for edge cases and boundary values
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Boundary Conditions', () => {
|
||||
it('should handle undefined parameters', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMoreLikeThisState({ controlClearMoreLikeThis: undefined, isLoading: undefined }),
|
||||
)
|
||||
|
||||
expect(result.current.childMessageId).toBeNull()
|
||||
expect(result.current.completionRes).toBe('')
|
||||
})
|
||||
|
||||
it('should handle empty string for completionRes', () => {
|
||||
const { result } = renderHook(() => useMoreLikeThisState({}))
|
||||
|
||||
act(() => {
|
||||
result.current.setCompletionRes('')
|
||||
})
|
||||
|
||||
expect(result.current.completionRes).toBe('')
|
||||
})
|
||||
|
||||
it('should handle multiple rapid state changes', () => {
|
||||
const { result } = renderHook(() => useMoreLikeThisState({}))
|
||||
|
||||
act(() => {
|
||||
result.current.setChildMessageId('first')
|
||||
result.current.setChildMessageId('second')
|
||||
result.current.setChildMessageId('third')
|
||||
})
|
||||
|
||||
expect(result.current.childMessageId).toBe('third')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// useWorkflowTabs Tests
|
||||
// ============================================================================
|
||||
describe('useWorkflowTabs', () => {
|
||||
// --------------------------------------------------------------------------
|
||||
// Initial State Tests
|
||||
// Tests verifying the hook's initial state based on workflowProcessData
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Initial State', () => {
|
||||
it('should initialize currentTab to DETAIL when no workflowProcessData', () => {
|
||||
const { result } = renderHook(() => useWorkflowTabs(undefined))
|
||||
expect(result.current.currentTab).toBe('DETAIL')
|
||||
})
|
||||
|
||||
it('should initialize showResultTabs to false when no workflowProcessData', () => {
|
||||
const { result } = renderHook(() => useWorkflowTabs(undefined))
|
||||
expect(result.current.showResultTabs).toBe(false)
|
||||
})
|
||||
|
||||
it('should set currentTab to RESULT when resultText is present', () => {
|
||||
const workflowData = { resultText: 'some result' } as WorkflowProcess
|
||||
const { result } = renderHook(() => useWorkflowTabs(workflowData))
|
||||
expect(result.current.currentTab).toBe('RESULT')
|
||||
})
|
||||
|
||||
it('should set currentTab to RESULT when files array has items', () => {
|
||||
const workflowData = { files: [{ id: 'file-1' }] } as WorkflowProcess
|
||||
const { result } = renderHook(() => useWorkflowTabs(workflowData))
|
||||
expect(result.current.currentTab).toBe('RESULT')
|
||||
})
|
||||
|
||||
it('should set showResultTabs to true when resultText is present', () => {
|
||||
const workflowData = { resultText: 'result' } as WorkflowProcess
|
||||
const { result } = renderHook(() => useWorkflowTabs(workflowData))
|
||||
expect(result.current.showResultTabs).toBe(true)
|
||||
})
|
||||
|
||||
it('should set showResultTabs to true when files array has items', () => {
|
||||
const workflowData = { files: [{ id: 'file-1' }] } as WorkflowProcess
|
||||
const { result } = renderHook(() => useWorkflowTabs(workflowData))
|
||||
expect(result.current.showResultTabs).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Tab Switching Tests
|
||||
// Tests for manual tab switching functionality
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Tab Switching', () => {
|
||||
it('should allow switching to DETAIL tab', () => {
|
||||
const workflowData = { resultText: 'result' } as WorkflowProcess
|
||||
const { result } = renderHook(() => useWorkflowTabs(workflowData))
|
||||
|
||||
expect(result.current.currentTab).toBe('RESULT')
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrentTab('DETAIL')
|
||||
})
|
||||
|
||||
expect(result.current.currentTab).toBe('DETAIL')
|
||||
})
|
||||
|
||||
it('should allow switching to RESULT tab', () => {
|
||||
const { result } = renderHook(() => useWorkflowTabs(undefined))
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrentTab('RESULT')
|
||||
})
|
||||
|
||||
expect(result.current.currentTab).toBe('RESULT')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Dynamic Data Change Tests
|
||||
// Tests for behavior when workflowProcessData changes
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Dynamic Data Changes', () => {
|
||||
it('should update currentTab when resultText becomes available', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ data }) => useWorkflowTabs(data),
|
||||
{ initialProps: { data: undefined as WorkflowProcess | undefined } },
|
||||
)
|
||||
|
||||
expect(result.current.currentTab).toBe('DETAIL')
|
||||
|
||||
rerender({ data: { resultText: 'new result' } as WorkflowProcess })
|
||||
|
||||
expect(result.current.currentTab).toBe('RESULT')
|
||||
})
|
||||
|
||||
it('should update currentTab when resultText is removed', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ data }) => useWorkflowTabs(data),
|
||||
{ initialProps: { data: { resultText: 'result' } as WorkflowProcess } },
|
||||
)
|
||||
|
||||
expect(result.current.currentTab).toBe('RESULT')
|
||||
|
||||
rerender({ data: { resultText: '' } as WorkflowProcess })
|
||||
|
||||
expect(result.current.currentTab).toBe('DETAIL')
|
||||
})
|
||||
|
||||
it('should update showResultTabs when files array changes', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ data }) => useWorkflowTabs(data),
|
||||
{ initialProps: { data: { files: [] } as unknown as WorkflowProcess } },
|
||||
)
|
||||
|
||||
expect(result.current.showResultTabs).toBe(false)
|
||||
|
||||
rerender({ data: { files: [{ id: 'file-1' }] } as WorkflowProcess })
|
||||
|
||||
expect(result.current.showResultTabs).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Boundary Conditions Tests
|
||||
// Tests for edge cases
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Boundary Conditions', () => {
|
||||
it('should handle empty resultText', () => {
|
||||
const workflowData = { resultText: '' } as WorkflowProcess
|
||||
const { result } = renderHook(() => useWorkflowTabs(workflowData))
|
||||
expect(result.current.showResultTabs).toBe(false)
|
||||
expect(result.current.currentTab).toBe('DETAIL')
|
||||
})
|
||||
|
||||
it('should handle empty files array', () => {
|
||||
const workflowData = { files: [] } as unknown as WorkflowProcess
|
||||
const { result } = renderHook(() => useWorkflowTabs(workflowData))
|
||||
expect(result.current.showResultTabs).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle undefined files', () => {
|
||||
const workflowData = { files: undefined } as WorkflowProcess
|
||||
const { result } = renderHook(() => useWorkflowTabs(workflowData))
|
||||
expect(result.current.showResultTabs).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle both resultText and files present', () => {
|
||||
const workflowData = {
|
||||
resultText: 'result',
|
||||
files: [{ id: 'file-1' }],
|
||||
} as WorkflowProcess
|
||||
const { result } = renderHook(() => useWorkflowTabs(workflowData))
|
||||
expect(result.current.showResultTabs).toBe(true)
|
||||
expect(result.current.currentTab).toBe('RESULT')
|
||||
})
|
||||
|
||||
it('should handle whitespace-only resultText as truthy', () => {
|
||||
const workflowData = { resultText: ' ' } as WorkflowProcess
|
||||
const { result } = renderHook(() => useWorkflowTabs(workflowData))
|
||||
// whitespace string is truthy
|
||||
expect(result.current.showResultTabs).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
67
web/app/components/app/text-generate/item/hooks.ts
Normal file
67
web/app/components/app/text-generate/item/hooks.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
|
||||
import type { WorkflowProcess } from '@/app/components/base/chat/types'
|
||||
|
||||
type UseMoreLikeThisStateParams = {
|
||||
controlClearMoreLikeThis?: number
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export const useMoreLikeThisState = ({
|
||||
controlClearMoreLikeThis,
|
||||
isLoading,
|
||||
}: UseMoreLikeThisStateParams) => {
|
||||
const [completionRes, setCompletionRes] = useState('')
|
||||
const [childMessageId, setChildMessageId] = useState<string | null>(null)
|
||||
const [childFeedback, setChildFeedback] = useState<FeedbackType>({
|
||||
rating: null,
|
||||
})
|
||||
const [isQuerying, { setTrue: startQuerying, setFalse: stopQuerying }] = useBoolean(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (controlClearMoreLikeThis) {
|
||||
setChildMessageId(null)
|
||||
setCompletionRes('')
|
||||
}
|
||||
}, [controlClearMoreLikeThis])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading)
|
||||
setChildMessageId(null)
|
||||
}, [isLoading])
|
||||
|
||||
return {
|
||||
completionRes,
|
||||
setCompletionRes,
|
||||
childMessageId,
|
||||
setChildMessageId,
|
||||
childFeedback,
|
||||
setChildFeedback,
|
||||
isQuerying,
|
||||
startQuerying,
|
||||
stopQuerying,
|
||||
}
|
||||
}
|
||||
|
||||
export const useWorkflowTabs = (workflowProcessData?: WorkflowProcess) => {
|
||||
const [currentTab, setCurrentTab] = useState<'DETAIL' | 'RESULT'>('DETAIL')
|
||||
const showResultTabs = !!workflowProcessData?.resultText || !!workflowProcessData?.files?.length
|
||||
|
||||
useEffect(() => {
|
||||
if (showResultTabs)
|
||||
setCurrentTab('RESULT')
|
||||
else
|
||||
setCurrentTab('DETAIL')
|
||||
}, [
|
||||
showResultTabs,
|
||||
workflowProcessData?.resultText,
|
||||
workflowProcessData?.files?.length,
|
||||
])
|
||||
|
||||
return {
|
||||
currentTab,
|
||||
setCurrentTab,
|
||||
showResultTabs,
|
||||
}
|
||||
}
|
||||
789
web/app/components/app/text-generate/item/index.spec.tsx
Normal file
789
web/app/components/app/text-generate/item/index.spec.tsx
Normal file
@@ -0,0 +1,789 @@
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import type { IGenerationItemProps } from './index'
|
||||
import GenerationItem from './index'
|
||||
|
||||
// ============================================================================
|
||||
// Mock Setup
|
||||
// ============================================================================
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useParams: jest.fn(() => ({ appId: 'app-123' })),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/base/chat/chat/context', () => ({
|
||||
useChatContext: () => ({
|
||||
config: { text_to_speech: { voice: 'en-test' } },
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/app/store', () => {
|
||||
const mockSetCurrentLogItem = jest.fn()
|
||||
const mockSetShowPromptLogModal = jest.fn()
|
||||
return {
|
||||
useStore: (selector: (state: Record<string, jest.Mock>) => jest.Mock) => {
|
||||
const store = {
|
||||
setCurrentLogItem: mockSetCurrentLogItem,
|
||||
setShowPromptLogModal: mockSetShowPromptLogModal,
|
||||
}
|
||||
return selector(store)
|
||||
},
|
||||
__mockSetCurrentLogItem: mockSetCurrentLogItem,
|
||||
__mockSetShowPromptLogModal: mockSetShowPromptLogModal,
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('@/app/components/base/toast', () => {
|
||||
const mockToastNotify = jest.fn()
|
||||
return {
|
||||
__esModule: true,
|
||||
default: { notify: mockToastNotify },
|
||||
__mockToastNotify: mockToastNotify,
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('copy-to-clipboard', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/service/share', () => {
|
||||
const mockFetchMoreLikeThis = jest.fn()
|
||||
const mockUpdateFeedback = jest.fn()
|
||||
return {
|
||||
__esModule: true,
|
||||
fetchMoreLikeThis: mockFetchMoreLikeThis,
|
||||
updateFeedback: mockUpdateFeedback,
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('@/service/debug', () => {
|
||||
const mockFetchTextGenerationMessage = jest.fn()
|
||||
return {
|
||||
__esModule: true,
|
||||
fetchTextGenerationMessage: mockFetchTextGenerationMessage,
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('@/app/components/base/loading', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="loading" />,
|
||||
}))
|
||||
|
||||
// Simple mock for ContentSection - just render content
|
||||
jest.mock('./content-section', () => {
|
||||
const mockContentSection = jest.fn()
|
||||
const Component = (props: Record<string, unknown>) => {
|
||||
mockContentSection(props)
|
||||
return (
|
||||
<div data-testid={`content-section-${props.taskId ?? 'none'}`}>
|
||||
{typeof props.content === 'string' ? props.content : 'content'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return {
|
||||
__esModule: true,
|
||||
default: Component,
|
||||
__mockContentSection: mockContentSection,
|
||||
}
|
||||
})
|
||||
|
||||
// Simple mock for MetaSection - expose action buttons based on props
|
||||
jest.mock('./meta-section', () => {
|
||||
const mockMetaSection = jest.fn()
|
||||
const Component = (props: Record<string, unknown>) => {
|
||||
mockMetaSection(props)
|
||||
const {
|
||||
messageId,
|
||||
isError,
|
||||
moreLikeThis,
|
||||
disableMoreLikeThis,
|
||||
canCopy,
|
||||
isInWebApp,
|
||||
isInstalledApp,
|
||||
isResponding,
|
||||
isWorkflow,
|
||||
supportFeedback,
|
||||
onOpenLogModal,
|
||||
onMoreLikeThis,
|
||||
onCopy,
|
||||
onRetry,
|
||||
onSave,
|
||||
onFeedback,
|
||||
showCharCount,
|
||||
charCount,
|
||||
} = props as {
|
||||
messageId?: string | null
|
||||
isError?: boolean
|
||||
moreLikeThis?: boolean
|
||||
disableMoreLikeThis?: boolean
|
||||
canCopy?: boolean
|
||||
isInWebApp?: boolean
|
||||
isInstalledApp?: boolean
|
||||
isResponding?: boolean
|
||||
isWorkflow?: boolean
|
||||
supportFeedback?: boolean
|
||||
onOpenLogModal?: () => void
|
||||
onMoreLikeThis?: () => void
|
||||
onCopy?: () => void
|
||||
onRetry?: () => void
|
||||
onSave?: (id: string) => void
|
||||
onFeedback?: (feedback: { rating: string | null }) => void
|
||||
showCharCount?: boolean
|
||||
charCount?: number
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid={`meta-section-${messageId ?? 'none'}`}>
|
||||
{showCharCount && (
|
||||
<span data-testid={`char-count-${messageId ?? 'none'}`}>{charCount}</span>
|
||||
)}
|
||||
{!isInWebApp && !isInstalledApp && !isResponding && (
|
||||
<button
|
||||
data-testid={`open-log-${messageId ?? 'none'}`}
|
||||
disabled={!messageId || isError}
|
||||
onClick={onOpenLogModal}
|
||||
>
|
||||
open-log
|
||||
</button>
|
||||
)}
|
||||
{moreLikeThis && (
|
||||
<button
|
||||
data-testid={`more-like-${messageId ?? 'none'}`}
|
||||
disabled={disableMoreLikeThis}
|
||||
onClick={onMoreLikeThis}
|
||||
>
|
||||
more-like
|
||||
</button>
|
||||
)}
|
||||
{canCopy && (
|
||||
<button
|
||||
data-testid={`copy-${messageId ?? 'none'}`}
|
||||
disabled={!messageId || isError}
|
||||
onClick={onCopy}
|
||||
>
|
||||
copy
|
||||
</button>
|
||||
)}
|
||||
{isInWebApp && isError && (
|
||||
<button data-testid={`retry-${messageId ?? 'none'}`} onClick={onRetry}>
|
||||
retry
|
||||
</button>
|
||||
)}
|
||||
{isInWebApp && !isWorkflow && (
|
||||
<button
|
||||
data-testid={`save-${messageId ?? 'none'}`}
|
||||
disabled={!messageId || isError}
|
||||
onClick={() => onSave?.(messageId as string)}
|
||||
>
|
||||
save
|
||||
</button>
|
||||
)}
|
||||
{(supportFeedback || isInWebApp) && !isWorkflow && !isError && messageId && onFeedback && (
|
||||
<button
|
||||
data-testid={`feedback-${messageId}`}
|
||||
onClick={() => onFeedback({ rating: 'like' })}
|
||||
>
|
||||
feedback
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return {
|
||||
__esModule: true,
|
||||
default: Component,
|
||||
__mockMetaSection: mockMetaSection,
|
||||
}
|
||||
})
|
||||
|
||||
const mockCopy = copy as jest.Mock
|
||||
const mockUseParams = jest.requireMock('next/navigation').useParams as jest.Mock
|
||||
const mockToastNotify = jest.requireMock('@/app/components/base/toast').__mockToastNotify as jest.Mock
|
||||
const mockSetCurrentLogItem = jest.requireMock('@/app/components/app/store').__mockSetCurrentLogItem as jest.Mock
|
||||
const mockSetShowPromptLogModal = jest.requireMock('@/app/components/app/store').__mockSetShowPromptLogModal as jest.Mock
|
||||
const mockFetchMoreLikeThis = jest.requireMock('@/service/share').fetchMoreLikeThis as jest.Mock
|
||||
const mockUpdateFeedback = jest.requireMock('@/service/share').updateFeedback as jest.Mock
|
||||
const mockFetchTextGenerationMessage = jest.requireMock('@/service/debug').fetchTextGenerationMessage as jest.Mock
|
||||
const mockContentSection = jest.requireMock('./content-section').__mockContentSection as jest.Mock
|
||||
const mockMetaSection = jest.requireMock('./meta-section').__mockMetaSection as jest.Mock
|
||||
|
||||
// ============================================================================
|
||||
// Test Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates base props with sensible defaults for GenerationItem testing.
|
||||
* Uses factory pattern for flexible test data creation.
|
||||
*/
|
||||
const createBaseProps = (overrides?: Partial<IGenerationItemProps>): IGenerationItemProps => ({
|
||||
isError: false,
|
||||
onRetry: jest.fn(),
|
||||
content: 'response text',
|
||||
messageId: 'message-1',
|
||||
moreLikeThis: true,
|
||||
isInstalledApp: false,
|
||||
siteInfo: null,
|
||||
supportFeedback: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
/**
|
||||
* Creates a deferred promise for testing async flows.
|
||||
*/
|
||||
const createDeferred = <T,>() => {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test Suite
|
||||
// ============================================================================
|
||||
|
||||
describe('GenerationItem', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockUseParams.mockReturnValue({ appId: 'app-123' })
|
||||
mockFetchMoreLikeThis.mockResolvedValue({ answer: 'child-answer', id: 'child-id' })
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Component Structure Tests
|
||||
// Tests verifying the component's export type and basic structure
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Component Structure', () => {
|
||||
it('should export a memoized component', () => {
|
||||
expect((GenerationItem as { $$typeof?: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Loading State Tests
|
||||
// Tests for the loading indicator display behavior
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Loading State', () => {
|
||||
it('should show loading indicator when isLoading is true', () => {
|
||||
render(<GenerationItem {...createBaseProps({ isLoading: true })} />)
|
||||
expect(screen.getByTestId('loading')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show loading indicator when isLoading is false', () => {
|
||||
render(<GenerationItem {...createBaseProps({ isLoading: false })} />)
|
||||
expect(screen.queryByTestId('loading')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show loading indicator when isLoading is undefined', () => {
|
||||
render(<GenerationItem {...createBaseProps()} />)
|
||||
expect(screen.queryByTestId('loading')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// More Like This Feature Tests
|
||||
// Tests for the "more like this" generation functionality
|
||||
// --------------------------------------------------------------------------
|
||||
describe('More Like This Feature', () => {
|
||||
it('should prevent request when message id is null', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<GenerationItem {...createBaseProps({ messageId: null })} />)
|
||||
|
||||
await user.click(screen.getByTestId('more-like-none'))
|
||||
|
||||
expect(mockFetchMoreLikeThis).not.toHaveBeenCalled()
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'warning',
|
||||
message: 'appDebug.errorMessage.waitForResponse',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should prevent request when message id is undefined', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<GenerationItem {...createBaseProps({ messageId: undefined })} />)
|
||||
|
||||
await user.click(screen.getByTestId('more-like-none'))
|
||||
|
||||
expect(mockFetchMoreLikeThis).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should guard against duplicate requests while querying', async () => {
|
||||
const user = userEvent.setup()
|
||||
const deferred = createDeferred<{ answer: string; id: string }>()
|
||||
mockFetchMoreLikeThis.mockReturnValueOnce(deferred.promise)
|
||||
|
||||
render(<GenerationItem {...createBaseProps()} />)
|
||||
|
||||
// First click starts query
|
||||
await user.click(screen.getByTestId('more-like-message-1'))
|
||||
await waitFor(() => expect(mockFetchMoreLikeThis).toHaveBeenCalledTimes(1))
|
||||
|
||||
// Second click while querying should show warning
|
||||
await user.click(screen.getByTestId('more-like-message-1'))
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'warning',
|
||||
message: 'appDebug.errorMessage.waitForResponse',
|
||||
}),
|
||||
)
|
||||
|
||||
// Resolve and verify child renders
|
||||
deferred.resolve({ answer: 'child-deferred', id: 'deferred-id' })
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('meta-section-deferred-id')).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('should fetch and render child item on successful request', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockFetchMoreLikeThis.mockResolvedValue({ answer: 'child content', id: 'child-generated' })
|
||||
|
||||
render(<GenerationItem {...createBaseProps()} />)
|
||||
|
||||
await user.click(screen.getByTestId('more-like-message-1'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockFetchMoreLikeThis).toHaveBeenCalledWith('message-1', false, undefined),
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('meta-section-child-generated')).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('should disable more-like-this button at maximum depth', () => {
|
||||
render(<GenerationItem {...createBaseProps({ depth: 3, messageId: 'max-depth' })} />)
|
||||
expect(screen.getByTestId('more-like-max-depth')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should clear generated child when controlClearMoreLikeThis changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockFetchMoreLikeThis.mockResolvedValue({ answer: 'child response', id: 'child-to-clear' })
|
||||
const baseProps = createBaseProps()
|
||||
const { rerender } = render(<GenerationItem {...baseProps} />)
|
||||
|
||||
await user.click(screen.getByTestId('more-like-message-1'))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('meta-section-child-to-clear')).toBeInTheDocument(),
|
||||
)
|
||||
|
||||
rerender(<GenerationItem {...baseProps} controlClearMoreLikeThis={1} />)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('meta-section-child-to-clear')).not.toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass correct installedAppId to fetchMoreLikeThis', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<GenerationItem
|
||||
{...createBaseProps({ isInstalledApp: true, installedAppId: 'install-123' })}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('more-like-message-1'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockFetchMoreLikeThis).toHaveBeenCalledWith('message-1', true, 'install-123'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Log Modal Tests
|
||||
// Tests for opening and formatting the log modal
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Log Modal', () => {
|
||||
it('should open log modal and format payload with array message', async () => {
|
||||
const user = userEvent.setup()
|
||||
const logResponse = {
|
||||
message: [{ role: 'user', text: 'hi' }],
|
||||
answer: 'assistant answer',
|
||||
message_files: [
|
||||
{ id: 'a1', belongs_to: 'assistant' },
|
||||
{ id: 'u1', belongs_to: 'user' },
|
||||
],
|
||||
}
|
||||
mockFetchTextGenerationMessage.mockResolvedValue(logResponse)
|
||||
|
||||
render(<GenerationItem {...createBaseProps({ messageId: 'log-id' })} />)
|
||||
|
||||
await user.click(screen.getByTestId('open-log-log-id'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockFetchTextGenerationMessage).toHaveBeenCalledWith({
|
||||
appId: 'app-123',
|
||||
messageId: 'log-id',
|
||||
}),
|
||||
)
|
||||
expect(mockSetCurrentLogItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
log: expect.arrayContaining([
|
||||
expect.objectContaining({ role: 'user', text: 'hi' }),
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
text: 'assistant answer',
|
||||
files: [{ id: 'a1', belongs_to: 'assistant' }],
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should format log payload with string message', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockFetchTextGenerationMessage.mockResolvedValue({
|
||||
message: 'simple string message',
|
||||
answer: 'response',
|
||||
})
|
||||
|
||||
render(<GenerationItem {...createBaseProps({ messageId: 'string-log' })} />)
|
||||
|
||||
await user.click(screen.getByTestId('open-log-string-log'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockSetCurrentLogItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
log: [{ text: 'simple string message' }],
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('should not fetch log when messageId is null', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<GenerationItem {...createBaseProps({ messageId: null })} />)
|
||||
|
||||
// Button should be disabled, but if clicked nothing happens
|
||||
const button = screen.getByTestId('open-log-none')
|
||||
expect(button).toBeDisabled()
|
||||
|
||||
await user.click(button)
|
||||
expect(mockFetchTextGenerationMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Copy Functionality Tests
|
||||
// Tests for copying content to clipboard
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Copy Functionality', () => {
|
||||
it('should copy plain string content', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<GenerationItem
|
||||
{...createBaseProps({ messageId: 'copy-plain', moreLikeThis: false, content: 'copy me' })}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('copy-copy-plain'))
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('copy me')
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'success',
|
||||
message: 'common.actionMsg.copySuccessfully',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should copy JSON stringified object content', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<GenerationItem
|
||||
{...createBaseProps({
|
||||
messageId: 'copy-object',
|
||||
moreLikeThis: false,
|
||||
content: { foo: 'bar' },
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('copy-copy-object'))
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(JSON.stringify({ foo: 'bar' }))
|
||||
})
|
||||
|
||||
it('should copy workflow result text when available', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<GenerationItem
|
||||
{...createBaseProps({
|
||||
messageId: 'workflow-id',
|
||||
isWorkflow: true,
|
||||
moreLikeThis: false,
|
||||
workflowProcessData: { resultText: 'workflow result' } as IGenerationItemProps['workflowProcessData'],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('copy-workflow-id')).toBeInTheDocument())
|
||||
await user.click(screen.getByTestId('copy-workflow-id'))
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('workflow result')
|
||||
})
|
||||
|
||||
it('should handle empty string content', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<GenerationItem
|
||||
{...createBaseProps({ messageId: 'copy-empty', moreLikeThis: false, content: '' })}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('copy-copy-empty'))
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Feedback Tests
|
||||
// Tests for the feedback functionality on child messages
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Feedback', () => {
|
||||
it('should update feedback for generated child message', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockFetchMoreLikeThis.mockResolvedValue({ answer: 'child response', id: 'child-feedback' })
|
||||
|
||||
render(
|
||||
<GenerationItem
|
||||
{...createBaseProps({ isInstalledApp: true, installedAppId: 'install-1' })}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('more-like-message-1'))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('feedback-child-feedback')).toBeInTheDocument(),
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('feedback-child-feedback'))
|
||||
|
||||
expect(mockUpdateFeedback).toHaveBeenCalledWith(
|
||||
{ url: '/messages/child-feedback/feedbacks', body: { rating: 'like' } },
|
||||
true,
|
||||
'install-1',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Props Passing Tests
|
||||
// Tests verifying correct props are passed to child components
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Props Passing', () => {
|
||||
it('should pass correct props to ContentSection', () => {
|
||||
render(
|
||||
<GenerationItem
|
||||
{...createBaseProps({
|
||||
taskId: 'task-123',
|
||||
isError: true,
|
||||
content: 'test content',
|
||||
hideProcessDetail: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockContentSection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
taskId: 'task-123',
|
||||
isError: true,
|
||||
content: 'test content',
|
||||
hideProcessDetail: true,
|
||||
depth: 1,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass correct props to MetaSection', () => {
|
||||
render(
|
||||
<GenerationItem
|
||||
{...createBaseProps({
|
||||
messageId: 'meta-test',
|
||||
isError: false,
|
||||
moreLikeThis: true,
|
||||
supportFeedback: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockMetaSection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messageId: 'meta-test',
|
||||
isError: false,
|
||||
moreLikeThis: true,
|
||||
supportFeedback: true,
|
||||
showCharCount: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should set showCharCount to false for workflow', () => {
|
||||
render(<GenerationItem {...createBaseProps({ isWorkflow: true })} />)
|
||||
|
||||
expect(mockMetaSection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
showCharCount: false,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Callback Tests
|
||||
// Tests verifying callbacks function correctly
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Callback Behavior', () => {
|
||||
it('should provide a working copy handler to MetaSection', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<GenerationItem
|
||||
{...createBaseProps({ messageId: 'callback-test', moreLikeThis: false, content: 'test copy' })}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('copy-callback-test'))
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('test copy')
|
||||
})
|
||||
|
||||
it('should provide a working more like this handler to MetaSection', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<GenerationItem {...createBaseProps({ messageId: 'callback-more' })} />)
|
||||
|
||||
await user.click(screen.getByTestId('more-like-callback-more'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockFetchMoreLikeThis).toHaveBeenCalledWith('callback-more', false, undefined),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Boundary Condition Tests
|
||||
// Tests for edge cases and boundary conditions
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Boundary Conditions', () => {
|
||||
it('should handle null content gracefully', () => {
|
||||
render(<GenerationItem {...createBaseProps({ content: null })} />)
|
||||
expect(screen.getByTestId('content-section-none')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined content gracefully', () => {
|
||||
render(<GenerationItem {...createBaseProps({ content: undefined })} />)
|
||||
expect(screen.getByTestId('content-section-none')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty object content', () => {
|
||||
render(<GenerationItem {...createBaseProps({ content: {} })} />)
|
||||
expect(screen.getByTestId('content-section-none')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle depth at boundary (depth = 1)', () => {
|
||||
render(<GenerationItem {...createBaseProps({ depth: 1 })} />)
|
||||
expect(mockMetaSection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
disableMoreLikeThis: false,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle depth at boundary (depth = 2)', () => {
|
||||
render(<GenerationItem {...createBaseProps({ depth: 2 })} />)
|
||||
expect(mockMetaSection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
disableMoreLikeThis: false,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle depth at maximum (depth = 3)', () => {
|
||||
render(<GenerationItem {...createBaseProps({ depth: 3 })} />)
|
||||
expect(mockMetaSection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
disableMoreLikeThis: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle missing appId in params', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockUseParams.mockReturnValue({})
|
||||
|
||||
render(<GenerationItem {...createBaseProps({ messageId: 'no-app' })} />)
|
||||
|
||||
await user.click(screen.getByTestId('open-log-no-app'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockFetchTextGenerationMessage).toHaveBeenCalledWith({
|
||||
appId: undefined,
|
||||
messageId: 'no-app',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should render with all optional props undefined', () => {
|
||||
const minimalProps: IGenerationItemProps = {
|
||||
isError: false,
|
||||
onRetry: jest.fn(),
|
||||
content: 'content',
|
||||
isInstalledApp: false,
|
||||
siteInfo: null,
|
||||
}
|
||||
render(<GenerationItem {...minimalProps} />)
|
||||
expect(screen.getByTestId('meta-section-none')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Error State Tests
|
||||
// Tests for error handling and error state display
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Error States', () => {
|
||||
it('should pass isError to child components', () => {
|
||||
render(<GenerationItem {...createBaseProps({ isError: true, messageId: 'error-id' })} />)
|
||||
|
||||
expect(mockContentSection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isError: true }),
|
||||
)
|
||||
expect(mockMetaSection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isError: true }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should show retry button in web app error state', () => {
|
||||
render(
|
||||
<GenerationItem
|
||||
{...createBaseProps({ isError: true, isInWebApp: true, messageId: 'retry-error' })}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('retry-retry-error')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable copy button on error', () => {
|
||||
render(
|
||||
<GenerationItem
|
||||
{...createBaseProps({ isError: true, messageId: 'copy-error', moreLikeThis: false })}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('copy-copy-error')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,36 +1,23 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiBookmark3Line,
|
||||
RiClipboardLine,
|
||||
RiFileList3Line,
|
||||
RiPlayList2Line,
|
||||
RiReplay15Line,
|
||||
RiSparklingFill,
|
||||
RiSparklingLine,
|
||||
RiThumbDownLine,
|
||||
RiThumbUpLine,
|
||||
} from '@remixicon/react'
|
||||
import { RiSparklingFill } from '@remixicon/react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import ResultTab from './result-tab'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
|
||||
import { fetchMoreLikeThis, updateFeedback } from '@/service/share'
|
||||
import { fetchTextGenerationMessage } from '@/service/debug'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import WorkflowProcessItem from '@/app/components/base/chat/chat/answer/workflow-process'
|
||||
import type { WorkflowProcess } from '@/app/components/base/chat/types'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import { useChatContext } from '@/app/components/base/chat/chat/context'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import NewAudioButton from '@/app/components/base/new-audio-button'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import ContentSection from './content-section'
|
||||
import MetaSection from './meta-section'
|
||||
import { useMoreLikeThisState, useWorkflowTabs } from './hooks'
|
||||
|
||||
const MAX_DEPTH = 3
|
||||
|
||||
@@ -69,6 +56,35 @@ export const copyIcon = (
|
||||
</svg>
|
||||
)
|
||||
|
||||
const formatLogItem = (data: any) => {
|
||||
if (Array.isArray(data.message)) {
|
||||
const assistantLog = data.message[data.message.length - 1]?.role !== 'assistant'
|
||||
? [{
|
||||
role: 'assistant',
|
||||
text: data.answer,
|
||||
files: data.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
|
||||
}]
|
||||
: []
|
||||
|
||||
return {
|
||||
...data,
|
||||
log: [
|
||||
...data.message,
|
||||
...assistantLog,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const message = typeof data.message === 'string'
|
||||
? { text: data.message }
|
||||
: data.message
|
||||
|
||||
return {
|
||||
...data,
|
||||
log: [message],
|
||||
}
|
||||
}
|
||||
|
||||
const GenerationItem: FC<IGenerationItemProps> = ({
|
||||
isWorkflow,
|
||||
workflowProcessData,
|
||||
@@ -99,33 +115,97 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
||||
const { t } = useTranslation()
|
||||
const params = useParams()
|
||||
const isTop = depth === 1
|
||||
const [completionRes, setCompletionRes] = useState('')
|
||||
const [childMessageId, setChildMessageId] = useState<string | null>(null)
|
||||
const [childFeedback, setChildFeedback] = useState<FeedbackType>({
|
||||
rating: null,
|
||||
})
|
||||
const {
|
||||
config,
|
||||
} = useChatContext()
|
||||
|
||||
completionRes,
|
||||
setCompletionRes,
|
||||
childMessageId,
|
||||
setChildMessageId,
|
||||
childFeedback,
|
||||
setChildFeedback,
|
||||
isQuerying,
|
||||
startQuerying,
|
||||
stopQuerying,
|
||||
} = useMoreLikeThisState({ controlClearMoreLikeThis, isLoading })
|
||||
const { currentTab, setCurrentTab, showResultTabs } = useWorkflowTabs(workflowProcessData)
|
||||
const { config } = useChatContext()
|
||||
const setCurrentLogItem = useAppStore(s => s.setCurrentLogItem)
|
||||
const setShowPromptLogModal = useAppStore(s => s.setShowPromptLogModal)
|
||||
|
||||
const handleFeedback = async (childFeedback: FeedbackType) => {
|
||||
await updateFeedback({ url: `/messages/${childMessageId}/feedbacks`, body: { rating: childFeedback.rating } }, isInstalledApp, installedAppId)
|
||||
setChildFeedback(childFeedback)
|
||||
}
|
||||
const handleFeedback = useCallback(async (nextFeedback: FeedbackType) => {
|
||||
if (!childMessageId)
|
||||
return
|
||||
await updateFeedback(
|
||||
{ url: `/messages/${childMessageId}/feedbacks`, body: { rating: nextFeedback.rating } },
|
||||
isInstalledApp,
|
||||
installedAppId,
|
||||
)
|
||||
setChildFeedback(nextFeedback)
|
||||
}, [childMessageId, installedAppId, isInstalledApp, setChildFeedback])
|
||||
|
||||
const [isQuerying, { setTrue: startQuerying, setFalse: stopQuerying }] = useBoolean(false)
|
||||
const handleMoreLikeThis = useCallback(async () => {
|
||||
if (isQuerying || !messageId) {
|
||||
Toast.notify({ type: 'warning', message: t('appDebug.errorMessage.waitForResponse') })
|
||||
return
|
||||
}
|
||||
startQuerying()
|
||||
try {
|
||||
const res: any = await fetchMoreLikeThis(messageId as string, isInstalledApp, installedAppId)
|
||||
setCompletionRes(res.answer)
|
||||
setChildFeedback({ rating: null })
|
||||
setChildMessageId(res.id)
|
||||
}
|
||||
finally {
|
||||
stopQuerying()
|
||||
}
|
||||
}, [
|
||||
installedAppId,
|
||||
isInstalledApp,
|
||||
isQuerying,
|
||||
messageId,
|
||||
setChildFeedback,
|
||||
setChildMessageId,
|
||||
setCompletionRes,
|
||||
startQuerying,
|
||||
stopQuerying,
|
||||
t,
|
||||
])
|
||||
|
||||
const childProps = {
|
||||
isInWebApp: true,
|
||||
const handleOpenLogModal = useCallback(async () => {
|
||||
if (!messageId)
|
||||
return
|
||||
const data = await fetchTextGenerationMessage({
|
||||
appId: params.appId as string,
|
||||
messageId,
|
||||
})
|
||||
const logItem = formatLogItem(data)
|
||||
setCurrentLogItem(logItem)
|
||||
setShowPromptLogModal(true)
|
||||
}, [messageId, params.appId, setCurrentLogItem, setShowPromptLogModal])
|
||||
|
||||
const copyContent = isWorkflow ? workflowProcessData?.resultText : content
|
||||
const handleCopy = useCallback(() => {
|
||||
if (typeof copyContent === 'string')
|
||||
copy(copyContent)
|
||||
else
|
||||
copy(JSON.stringify(copyContent))
|
||||
Toast.notify({ type: 'success', message: t('common.actionMsg.copySuccessfully') })
|
||||
}, [copyContent, t])
|
||||
|
||||
const shouldIndentForChild = Boolean(isMobile && (childMessageId || isQuerying) && depth < MAX_DEPTH)
|
||||
const shouldRenderChild = (childMessageId || isQuerying) && depth < MAX_DEPTH
|
||||
const canCopy = (currentTab === 'RESULT' && workflowProcessData?.resultText) || !isWorkflow
|
||||
const childProps: IGenerationItemProps = {
|
||||
isWorkflow,
|
||||
className,
|
||||
isError: false,
|
||||
onRetry,
|
||||
content: completionRes,
|
||||
messageId: childMessageId,
|
||||
depth: depth + 1,
|
||||
moreLikeThis: true,
|
||||
onFeedback: handleFeedback,
|
||||
isLoading: isQuerying,
|
||||
isResponding,
|
||||
moreLikeThis: true,
|
||||
depth: depth + 1,
|
||||
onFeedback: handleFeedback,
|
||||
feedback: childFeedback,
|
||||
onSave,
|
||||
isShowTextToSpeech,
|
||||
@@ -133,80 +213,13 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
||||
isInstalledApp,
|
||||
installedAppId,
|
||||
controlClearMoreLikeThis,
|
||||
isWorkflow,
|
||||
isInWebApp: true,
|
||||
siteInfo,
|
||||
taskId,
|
||||
inSidePanel,
|
||||
hideProcessDetail,
|
||||
}
|
||||
|
||||
const handleMoreLikeThis = async () => {
|
||||
if (isQuerying || !messageId) {
|
||||
Toast.notify({ type: 'warning', message: t('appDebug.errorMessage.waitForResponse') })
|
||||
return
|
||||
}
|
||||
startQuerying()
|
||||
const res: any = await fetchMoreLikeThis(messageId as string, isInstalledApp, installedAppId)
|
||||
setCompletionRes(res.answer)
|
||||
setChildFeedback({
|
||||
rating: null,
|
||||
})
|
||||
setChildMessageId(res.id)
|
||||
stopQuerying()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (controlClearMoreLikeThis) {
|
||||
setChildMessageId(null)
|
||||
setCompletionRes('')
|
||||
}
|
||||
}, [controlClearMoreLikeThis])
|
||||
|
||||
// regeneration clear child
|
||||
useEffect(() => {
|
||||
if (isLoading)
|
||||
setChildMessageId(null)
|
||||
}, [isLoading])
|
||||
|
||||
const handleOpenLogModal = async () => {
|
||||
const data = await fetchTextGenerationMessage({
|
||||
appId: params.appId as string,
|
||||
messageId: messageId!,
|
||||
})
|
||||
const logItem = Array.isArray(data.message) ? {
|
||||
...data,
|
||||
log: [
|
||||
...data.message,
|
||||
...(data.message[data.message.length - 1].role !== 'assistant'
|
||||
? [
|
||||
{
|
||||
role: 'assistant',
|
||||
text: data.answer,
|
||||
files: data.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
} : {
|
||||
...data,
|
||||
log: [typeof data.message === 'string' ? {
|
||||
text: data.message,
|
||||
} : data.message],
|
||||
}
|
||||
setCurrentLogItem(logItem)
|
||||
setShowPromptLogModal(true)
|
||||
}
|
||||
|
||||
const [currentTab, setCurrentTab] = useState<string>('DETAIL')
|
||||
const showResultTabs = !!workflowProcessData?.resultText || !!workflowProcessData?.files?.length
|
||||
const switchTab = async (tab: string) => {
|
||||
setCurrentTab(tab)
|
||||
}
|
||||
useEffect(() => {
|
||||
if (workflowProcessData?.resultText || !!workflowProcessData?.files?.length)
|
||||
switchTab('RESULT')
|
||||
else
|
||||
switchTab('DETAIL')
|
||||
}, [workflowProcessData?.files?.length, workflowProcessData?.resultText])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('relative', !isTop && 'mt-3', className)}>
|
||||
@@ -215,152 +228,45 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
||||
)}
|
||||
{!isLoading && (
|
||||
<>
|
||||
{/* result content */}
|
||||
<div className={cn(
|
||||
'relative',
|
||||
!inSidePanel && 'rounded-2xl border-t border-divider-subtle bg-chat-bubble-bg',
|
||||
)}>
|
||||
{workflowProcessData && (
|
||||
<>
|
||||
<div className={cn(
|
||||
'p-3',
|
||||
showResultTabs && 'border-b border-divider-subtle',
|
||||
)}>
|
||||
{taskId && (
|
||||
<div className={cn('system-2xs-medium-uppercase mb-2 flex items-center text-text-accent-secondary', isError && 'text-text-destructive')}>
|
||||
<RiPlayList2Line className='mr-1 h-3 w-3' />
|
||||
<span>{t('share.generation.execution')}</span>
|
||||
<span className='px-1'>·</span>
|
||||
<span>{taskId}</span>
|
||||
</div>
|
||||
)}
|
||||
{siteInfo && workflowProcessData && (
|
||||
<WorkflowProcessItem
|
||||
data={workflowProcessData}
|
||||
expand={workflowProcessData.expand}
|
||||
hideProcessDetail={hideProcessDetail}
|
||||
hideInfo={hideProcessDetail}
|
||||
readonly={!siteInfo.show_workflow_steps}
|
||||
/>
|
||||
)}
|
||||
{showResultTabs && (
|
||||
<div className='flex items-center space-x-6 px-1'>
|
||||
<div
|
||||
className={cn(
|
||||
'system-sm-semibold-uppercase cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary',
|
||||
currentTab === 'RESULT' && 'border-util-colors-blue-brand-blue-brand-600 text-text-primary',
|
||||
)}
|
||||
onClick={() => switchTab('RESULT')}
|
||||
>{t('runLog.result')}</div>
|
||||
<div
|
||||
className={cn(
|
||||
'system-sm-semibold-uppercase cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary',
|
||||
currentTab === 'DETAIL' && 'border-util-colors-blue-brand-blue-brand-600 text-text-primary',
|
||||
)}
|
||||
onClick={() => switchTab('DETAIL')}
|
||||
>{t('runLog.detail')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isError && (
|
||||
<ResultTab data={workflowProcessData} content={content} currentTab={currentTab} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!workflowProcessData && taskId && (
|
||||
<div className={cn('system-2xs-medium-uppercase sticky left-0 top-0 flex w-full items-center rounded-t-2xl bg-components-actionbar-bg p-4 pb-3 text-text-accent-secondary', isError && 'text-text-destructive')}>
|
||||
<RiPlayList2Line className='mr-1 h-3 w-3' />
|
||||
<span>{t('share.generation.execution')}</span>
|
||||
<span className='px-1'>·</span>
|
||||
<span>{`${taskId}${depth > 1 ? `-${depth - 1}` : ''}`}</span>
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<div className='body-lg-regular p-4 pt-0 text-text-quaternary'>{t('share.generation.batchFailed.outputPlaceholder')}</div>
|
||||
)}
|
||||
{!workflowProcessData && !isError && (typeof content === 'string') && (
|
||||
<div className={cn('p-4', taskId && 'pt-0')}>
|
||||
<Markdown content={content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* meta data */}
|
||||
<div className={cn(
|
||||
'system-xs-regular relative mt-1 h-4 px-4 text-text-quaternary',
|
||||
isMobile && ((childMessageId || isQuerying) && depth < 3) && 'pl-10',
|
||||
)}>
|
||||
{!isWorkflow && <span>{content?.length} {t('common.unit.char')}</span>}
|
||||
{/* action buttons */}
|
||||
<div className='absolute bottom-1 right-2 flex items-center'>
|
||||
{!isInWebApp && !isInstalledApp && !isResponding && (
|
||||
<div className='ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm'>
|
||||
<ActionButton disabled={isError || !messageId} onClick={handleOpenLogModal}>
|
||||
<RiFileList3Line className='h-4 w-4' />
|
||||
{/* <div>{t('common.operation.log')}</div> */}
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
<div className='ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm'>
|
||||
{moreLikeThis && (
|
||||
<ActionButton state={depth === MAX_DEPTH ? ActionButtonState.Disabled : ActionButtonState.Default} disabled={depth === MAX_DEPTH} onClick={handleMoreLikeThis}>
|
||||
<RiSparklingLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)}
|
||||
{isShowTextToSpeech && (
|
||||
<NewAudioButton
|
||||
id={messageId!}
|
||||
voice={config?.text_to_speech?.voice}
|
||||
/>
|
||||
)}
|
||||
{((currentTab === 'RESULT' && workflowProcessData?.resultText) || !isWorkflow) && (
|
||||
<ActionButton disabled={isError || !messageId} onClick={() => {
|
||||
const copyContent = isWorkflow ? workflowProcessData?.resultText : content
|
||||
if (typeof copyContent === 'string')
|
||||
copy(copyContent)
|
||||
else
|
||||
copy(JSON.stringify(copyContent))
|
||||
Toast.notify({ type: 'success', message: t('common.actionMsg.copySuccessfully') })
|
||||
}}>
|
||||
<RiClipboardLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)}
|
||||
{isInWebApp && isError && (
|
||||
<ActionButton onClick={onRetry}>
|
||||
<RiReplay15Line className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)}
|
||||
{isInWebApp && !isWorkflow && (
|
||||
<ActionButton disabled={isError || !messageId} onClick={() => { onSave?.(messageId as string) }}>
|
||||
<RiBookmark3Line className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
{(supportFeedback || isInWebApp) && !isWorkflow && !isError && messageId && (
|
||||
<div className='ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm'>
|
||||
{!feedback?.rating && (
|
||||
<>
|
||||
<ActionButton onClick={() => onFeedback?.({ rating: 'like' })}>
|
||||
<RiThumbUpLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
<ActionButton onClick={() => onFeedback?.({ rating: 'dislike' })}>
|
||||
<RiThumbDownLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</>
|
||||
)}
|
||||
{feedback?.rating === 'like' && (
|
||||
<ActionButton state={ActionButtonState.Active} onClick={() => onFeedback?.({ rating: null })}>
|
||||
<RiThumbUpLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)}
|
||||
{feedback?.rating === 'dislike' && (
|
||||
<ActionButton state={ActionButtonState.Destructive} onClick={() => onFeedback?.({ rating: null })}>
|
||||
<RiThumbDownLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ContentSection
|
||||
workflowProcessData={workflowProcessData}
|
||||
taskId={taskId}
|
||||
depth={depth}
|
||||
isError={isError}
|
||||
content={content}
|
||||
hideProcessDetail={hideProcessDetail}
|
||||
siteInfo={siteInfo}
|
||||
currentTab={currentTab}
|
||||
onSwitchTab={setCurrentTab}
|
||||
showResultTabs={showResultTabs}
|
||||
t={t}
|
||||
inSidePanel={inSidePanel}
|
||||
/>
|
||||
<MetaSection
|
||||
showCharCount={!isWorkflow}
|
||||
charCount={content?.length}
|
||||
t={t}
|
||||
shouldIndentForChild={shouldIndentForChild}
|
||||
isInWebApp={isInWebApp}
|
||||
isInstalledApp={isInstalledApp}
|
||||
isResponding={isResponding}
|
||||
isError={isError}
|
||||
messageId={messageId}
|
||||
onOpenLogModal={handleOpenLogModal}
|
||||
moreLikeThis={moreLikeThis}
|
||||
onMoreLikeThis={handleMoreLikeThis}
|
||||
disableMoreLikeThis={depth === MAX_DEPTH}
|
||||
isShowTextToSpeech={isShowTextToSpeech}
|
||||
textToSpeechVoice={config?.text_to_speech?.voice}
|
||||
canCopy={!!canCopy}
|
||||
onCopy={handleCopy}
|
||||
onRetry={onRetry}
|
||||
isWorkflow={isWorkflow}
|
||||
onSave={onSave}
|
||||
feedback={feedback}
|
||||
onFeedback={onFeedback}
|
||||
supportFeedback={supportFeedback}
|
||||
/>
|
||||
{/* more like this elements */}
|
||||
{!isTop && (
|
||||
<div className={cn(
|
||||
@@ -379,8 +285,8 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{((childMessageId || isQuerying) && depth < 3) && (
|
||||
<GenerationItem {...childProps as any} />
|
||||
{shouldRenderChild && (
|
||||
<GenerationItem {...childProps} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
720
web/app/components/app/text-generate/item/meta-section.spec.tsx
Normal file
720
web/app/components/app/text-generate/item/meta-section.spec.tsx
Normal file
@@ -0,0 +1,720 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { TFunction } from 'i18next'
|
||||
import MetaSection from './meta-section'
|
||||
|
||||
// ============================================================================
|
||||
// Mock Setup
|
||||
// ============================================================================
|
||||
|
||||
jest.mock('@/app/components/base/action-button', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
state,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
disabled?: boolean
|
||||
onClick?: () => void
|
||||
state?: string
|
||||
}) => (
|
||||
<button
|
||||
data-disabled={disabled}
|
||||
data-state={state}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
ActionButtonState: {
|
||||
Default: 'default',
|
||||
Active: 'active',
|
||||
Disabled: 'disabled',
|
||||
Destructive: 'destructive',
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/base/new-audio-button', () => ({
|
||||
__esModule: true,
|
||||
default: ({ id, voice }: { id: string; voice?: string }) => (
|
||||
<button data-testid="audio-button" data-id={id} data-voice={voice}>
|
||||
Audio
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test Utilities
|
||||
// ============================================================================
|
||||
|
||||
const mockT = ((key: string) => key) as TFunction
|
||||
|
||||
/**
|
||||
* Creates base props with sensible defaults for MetaSection testing.
|
||||
*/
|
||||
const createBaseProps = (overrides?: Partial<Parameters<typeof MetaSection>[0]>) => ({
|
||||
showCharCount: false,
|
||||
t: mockT,
|
||||
shouldIndentForChild: false,
|
||||
isInWebApp: false,
|
||||
isInstalledApp: false,
|
||||
isResponding: false,
|
||||
isError: false,
|
||||
messageId: 'msg-123',
|
||||
onOpenLogModal: jest.fn(),
|
||||
moreLikeThis: false,
|
||||
onMoreLikeThis: jest.fn(),
|
||||
disableMoreLikeThis: false,
|
||||
isShowTextToSpeech: false,
|
||||
canCopy: true,
|
||||
onCopy: jest.fn(),
|
||||
onRetry: jest.fn(),
|
||||
isWorkflow: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Test Suite
|
||||
// ============================================================================
|
||||
|
||||
describe('MetaSection', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Character Count Tests
|
||||
// Tests for character count display
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Character Count', () => {
|
||||
it('should show character count when showCharCount is true', () => {
|
||||
render(<MetaSection {...createBaseProps({ showCharCount: true, charCount: 150 })} />)
|
||||
expect(screen.getByText(/150/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/common.unit.char/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show character count when showCharCount is false', () => {
|
||||
render(<MetaSection {...createBaseProps({ showCharCount: false, charCount: 150 })} />)
|
||||
expect(screen.queryByText(/150/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle zero character count', () => {
|
||||
render(<MetaSection {...createBaseProps({ showCharCount: true, charCount: 0 })} />)
|
||||
expect(screen.getByText(/0/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined character count', () => {
|
||||
render(<MetaSection {...createBaseProps({ showCharCount: true, charCount: undefined })} />)
|
||||
expect(screen.getByText(/common.unit.char/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Log Modal Button Tests
|
||||
// Tests for the log modal button visibility and behavior
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Log Modal Button', () => {
|
||||
it('should show log button when not in web app, not installed app, and not responding', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
isInWebApp: false,
|
||||
isInstalledApp: false,
|
||||
isResponding: false,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
// Find the button with RiFileList3Line icon (log button)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const logButton = buttons.find(btn => !btn.hasAttribute('data-testid'))
|
||||
expect(logButton).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not show log button when isInWebApp is true', () => {
|
||||
const onOpenLogModal = jest.fn()
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
isInWebApp: true,
|
||||
isInstalledApp: false,
|
||||
isResponding: false,
|
||||
onOpenLogModal,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
// Log button should not be rendered
|
||||
// The component structure means we need to check differently
|
||||
})
|
||||
|
||||
it('should not show log button when isInstalledApp is true', () => {
|
||||
const onOpenLogModal = jest.fn()
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
isInWebApp: false,
|
||||
isInstalledApp: true,
|
||||
isResponding: false,
|
||||
onOpenLogModal,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
})
|
||||
|
||||
it('should not show log button when isResponding is true', () => {
|
||||
const onOpenLogModal = jest.fn()
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
isInWebApp: false,
|
||||
isInstalledApp: false,
|
||||
isResponding: true,
|
||||
onOpenLogModal,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
})
|
||||
|
||||
it('should disable log button when isError is true', () => {
|
||||
const onOpenLogModal = jest.fn()
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
isInWebApp: false,
|
||||
isInstalledApp: false,
|
||||
isResponding: false,
|
||||
isError: true,
|
||||
onOpenLogModal,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const disabledButton = buttons.find(btn => btn.hasAttribute('disabled'))
|
||||
expect(disabledButton).toBeDefined()
|
||||
})
|
||||
|
||||
it('should disable log button when messageId is null', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
isInWebApp: false,
|
||||
isInstalledApp: false,
|
||||
isResponding: false,
|
||||
messageId: null,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const disabledButton = buttons.find(btn => btn.hasAttribute('disabled'))
|
||||
expect(disabledButton).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// More Like This Button Tests
|
||||
// Tests for the "more like this" button
|
||||
// --------------------------------------------------------------------------
|
||||
describe('More Like This Button', () => {
|
||||
it('should show more like this button when moreLikeThis is true', () => {
|
||||
render(<MetaSection {...createBaseProps({ moreLikeThis: true })} />)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should not show more like this button when moreLikeThis is false', () => {
|
||||
render(<MetaSection {...createBaseProps({ moreLikeThis: false })} />)
|
||||
// Button count should be lower
|
||||
})
|
||||
|
||||
it('should disable more like this button when disableMoreLikeThis is true', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
moreLikeThis: true,
|
||||
disableMoreLikeThis: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const disabledButton = buttons.find(
|
||||
btn => btn.getAttribute('data-state') === 'disabled' || btn.hasAttribute('disabled'),
|
||||
)
|
||||
expect(disabledButton).toBeDefined()
|
||||
})
|
||||
|
||||
it('should call onMoreLikeThis when button is clicked', () => {
|
||||
const onMoreLikeThis = jest.fn()
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
moreLikeThis: true,
|
||||
disableMoreLikeThis: false,
|
||||
onMoreLikeThis,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
// Find the more like this button (first non-disabled button)
|
||||
const moreButton = buttons.find(btn => !btn.hasAttribute('disabled'))
|
||||
if (moreButton)
|
||||
fireEvent.click(moreButton)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Text to Speech Button Tests
|
||||
// Tests for the audio/TTS button
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Text to Speech Button', () => {
|
||||
it('should show audio button when isShowTextToSpeech is true and messageId exists', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
isShowTextToSpeech: true,
|
||||
messageId: 'audio-msg',
|
||||
textToSpeechVoice: 'en-US',
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const audioButton = screen.getByTestId('audio-button')
|
||||
expect(audioButton).toBeInTheDocument()
|
||||
expect(audioButton).toHaveAttribute('data-id', 'audio-msg')
|
||||
expect(audioButton).toHaveAttribute('data-voice', 'en-US')
|
||||
})
|
||||
|
||||
it('should not show audio button when isShowTextToSpeech is false', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
isShowTextToSpeech: false,
|
||||
messageId: 'audio-msg',
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByTestId('audio-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show audio button when messageId is null', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
isShowTextToSpeech: true,
|
||||
messageId: null,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByTestId('audio-button')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Copy Button Tests
|
||||
// Tests for the copy button
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Copy Button', () => {
|
||||
it('should show copy button when canCopy is true', () => {
|
||||
const onCopy = jest.fn()
|
||||
render(<MetaSection {...createBaseProps({ canCopy: true, onCopy })} />)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should not show copy button when canCopy is false', () => {
|
||||
render(<MetaSection {...createBaseProps({ canCopy: false })} />)
|
||||
// Copy button should not be rendered
|
||||
})
|
||||
|
||||
it('should disable copy button when isError is true', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
canCopy: true,
|
||||
isError: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const disabledButton = buttons.find(btn => btn.hasAttribute('disabled'))
|
||||
expect(disabledButton).toBeDefined()
|
||||
})
|
||||
|
||||
it('should disable copy button when messageId is null', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
canCopy: true,
|
||||
messageId: null,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const disabledButton = buttons.find(btn => btn.hasAttribute('disabled'))
|
||||
expect(disabledButton).toBeDefined()
|
||||
})
|
||||
|
||||
it('should call onCopy when button is clicked', () => {
|
||||
const onCopy = jest.fn()
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
canCopy: true,
|
||||
messageId: 'copy-msg',
|
||||
isError: false,
|
||||
onCopy,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const copyButton = buttons.find(btn => !btn.hasAttribute('disabled'))
|
||||
if (copyButton)
|
||||
fireEvent.click(copyButton)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Retry Button Tests
|
||||
// Tests for the retry button in error state
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Retry Button', () => {
|
||||
it('should show retry button when isInWebApp is true and isError is true', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
isInWebApp: true,
|
||||
isError: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should not show retry button when isInWebApp is false', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
isInWebApp: false,
|
||||
isError: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
// Retry button should not render
|
||||
})
|
||||
|
||||
it('should not show retry button when isError is false', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
isInWebApp: true,
|
||||
isError: false,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
// Retry button should not render
|
||||
})
|
||||
|
||||
it('should call onRetry when retry button is clicked', () => {
|
||||
const onRetry = jest.fn()
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
isInWebApp: true,
|
||||
isError: true,
|
||||
onRetry,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const retryButton = buttons.find(btn => !btn.hasAttribute('disabled'))
|
||||
if (retryButton)
|
||||
fireEvent.click(retryButton)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Save Button Tests
|
||||
// Tests for the save/bookmark button
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Save Button', () => {
|
||||
it('should show save button when isInWebApp is true and not workflow', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
isInWebApp: true,
|
||||
isWorkflow: false,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should not show save button when isWorkflow is true', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
isInWebApp: true,
|
||||
isWorkflow: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
// Save button should not render in workflow mode
|
||||
})
|
||||
|
||||
it('should disable save button when isError is true', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
isInWebApp: true,
|
||||
isWorkflow: false,
|
||||
isError: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const disabledButton = buttons.find(btn => btn.hasAttribute('disabled'))
|
||||
expect(disabledButton).toBeDefined()
|
||||
})
|
||||
|
||||
it('should disable save button when messageId is null', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
isInWebApp: true,
|
||||
isWorkflow: false,
|
||||
messageId: null,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const disabledButton = buttons.find(btn => btn.hasAttribute('disabled'))
|
||||
expect(disabledButton).toBeDefined()
|
||||
})
|
||||
|
||||
it('should call onSave with messageId when button is clicked', () => {
|
||||
const onSave = jest.fn()
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
isInWebApp: true,
|
||||
isWorkflow: false,
|
||||
messageId: 'save-msg',
|
||||
isError: false,
|
||||
onSave,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const saveButton = buttons.find(btn => !btn.hasAttribute('disabled'))
|
||||
if (saveButton)
|
||||
fireEvent.click(saveButton)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Feedback Section Tests
|
||||
// Tests for the feedback (like/dislike) buttons
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Feedback Section', () => {
|
||||
it('should show feedback when supportFeedback is true and conditions are met', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
supportFeedback: true,
|
||||
isWorkflow: false,
|
||||
isError: false,
|
||||
messageId: 'feedback-msg',
|
||||
onFeedback: jest.fn(),
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should show feedback when isInWebApp is true', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
isInWebApp: true,
|
||||
isWorkflow: false,
|
||||
isError: false,
|
||||
messageId: 'feedback-msg',
|
||||
onFeedback: jest.fn(),
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should not show feedback when isWorkflow is true', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
supportFeedback: true,
|
||||
isWorkflow: true,
|
||||
messageId: 'feedback-msg',
|
||||
onFeedback: jest.fn(),
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
// Feedback section should not render
|
||||
})
|
||||
|
||||
it('should not show feedback when isError is true', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
supportFeedback: true,
|
||||
isWorkflow: false,
|
||||
isError: true,
|
||||
messageId: 'feedback-msg',
|
||||
onFeedback: jest.fn(),
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
// Feedback section should not render
|
||||
})
|
||||
|
||||
it('should not show feedback when messageId is null', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
supportFeedback: true,
|
||||
isWorkflow: false,
|
||||
isError: false,
|
||||
messageId: null,
|
||||
onFeedback: jest.fn(),
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
// Feedback section should not render
|
||||
})
|
||||
|
||||
it('should not show feedback when onFeedback is undefined', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
supportFeedback: true,
|
||||
isWorkflow: false,
|
||||
isError: false,
|
||||
messageId: 'feedback-msg',
|
||||
onFeedback: undefined,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
// Feedback section should not render
|
||||
})
|
||||
|
||||
it('should handle like feedback state', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
supportFeedback: true,
|
||||
isWorkflow: false,
|
||||
isError: false,
|
||||
messageId: 'feedback-msg',
|
||||
feedback: { rating: 'like' },
|
||||
onFeedback: jest.fn(),
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const activeButton = buttons.find(btn => btn.getAttribute('data-state') === 'active')
|
||||
expect(activeButton).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle dislike feedback state', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
supportFeedback: true,
|
||||
isWorkflow: false,
|
||||
isError: false,
|
||||
messageId: 'feedback-msg',
|
||||
feedback: { rating: 'dislike' },
|
||||
onFeedback: jest.fn(),
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const destructiveButton = buttons.find(btn => btn.getAttribute('data-state') === 'destructive')
|
||||
expect(destructiveButton).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Indentation Tests
|
||||
// Tests for the child indentation styling
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Indentation', () => {
|
||||
it('should apply indent class when shouldIndentForChild is true', () => {
|
||||
const { container } = render(
|
||||
<MetaSection {...createBaseProps({ shouldIndentForChild: true })} />,
|
||||
)
|
||||
expect(container.firstChild).toHaveClass('pl-10')
|
||||
})
|
||||
|
||||
it('should not apply indent class when shouldIndentForChild is false', () => {
|
||||
const { container } = render(
|
||||
<MetaSection {...createBaseProps({ shouldIndentForChild: false })} />,
|
||||
)
|
||||
expect(container.firstChild).not.toHaveClass('pl-10')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Boundary Conditions Tests
|
||||
// Tests for edge cases
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Boundary Conditions', () => {
|
||||
it('should handle all props being minimal', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
showCharCount: false,
|
||||
moreLikeThis: false,
|
||||
canCopy: false,
|
||||
isShowTextToSpeech: false,
|
||||
isInWebApp: false,
|
||||
supportFeedback: false,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
// Should render without crashing
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThan(0) // At least log button
|
||||
})
|
||||
|
||||
it('should handle undefined messageId', () => {
|
||||
render(<MetaSection {...createBaseProps({ messageId: undefined })} />)
|
||||
// Should render without crashing
|
||||
})
|
||||
|
||||
it('should handle empty string messageId', () => {
|
||||
render(<MetaSection {...createBaseProps({ messageId: '' })} />)
|
||||
// Empty string is falsy, so buttons should be disabled
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const disabledButton = buttons.find(btn => btn.hasAttribute('disabled'))
|
||||
expect(disabledButton).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle undefined textToSpeechVoice', () => {
|
||||
render(
|
||||
<MetaSection
|
||||
{...createBaseProps({
|
||||
isShowTextToSpeech: true,
|
||||
messageId: 'voice-msg',
|
||||
textToSpeechVoice: undefined,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const audioButton = screen.getByTestId('audio-button')
|
||||
// undefined becomes null in data attribute
|
||||
expect(audioButton).not.toHaveAttribute('data-voice', 'some-value')
|
||||
})
|
||||
})
|
||||
})
|
||||
157
web/app/components/app/text-generate/item/meta-section.tsx
Normal file
157
web/app/components/app/text-generate/item/meta-section.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { FC } from 'react'
|
||||
import type { TFunction } from 'i18next'
|
||||
import {
|
||||
RiBookmark3Line,
|
||||
RiClipboardLine,
|
||||
RiFileList3Line,
|
||||
RiReplay15Line,
|
||||
RiSparklingLine,
|
||||
RiThumbDownLine,
|
||||
RiThumbUpLine,
|
||||
} from '@remixicon/react'
|
||||
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import NewAudioButton from '@/app/components/base/new-audio-button'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type FeedbackActionsProps = {
|
||||
feedback?: FeedbackType
|
||||
onFeedback?: (feedback: FeedbackType) => void
|
||||
}
|
||||
|
||||
const FeedbackActions: FC<FeedbackActionsProps> = ({
|
||||
feedback,
|
||||
onFeedback,
|
||||
}) => {
|
||||
if (!feedback?.rating) {
|
||||
return (
|
||||
<>
|
||||
<ActionButton onClick={() => onFeedback?.({ rating: 'like' })}>
|
||||
<RiThumbUpLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
<ActionButton onClick={() => onFeedback?.({ rating: 'dislike' })}>
|
||||
<RiThumbDownLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (feedback.rating === 'like') {
|
||||
return (
|
||||
<ActionButton state={ActionButtonState.Active} onClick={() => onFeedback?.({ rating: null })}>
|
||||
<RiThumbUpLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionButton state={ActionButtonState.Destructive} onClick={() => onFeedback?.({ rating: null })}>
|
||||
<RiThumbDownLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
type MetaSectionProps = {
|
||||
showCharCount: boolean
|
||||
charCount?: number
|
||||
t: TFunction
|
||||
shouldIndentForChild: boolean
|
||||
isInWebApp?: boolean
|
||||
isInstalledApp: boolean
|
||||
isResponding?: boolean
|
||||
isError: boolean
|
||||
messageId?: string | null
|
||||
onOpenLogModal: () => void
|
||||
moreLikeThis?: boolean
|
||||
onMoreLikeThis: () => void
|
||||
disableMoreLikeThis: boolean
|
||||
isShowTextToSpeech?: boolean
|
||||
textToSpeechVoice?: string
|
||||
canCopy: boolean
|
||||
onCopy: () => void
|
||||
onRetry: () => void
|
||||
isWorkflow?: boolean
|
||||
onSave?: (messageId: string) => void
|
||||
feedback?: FeedbackType
|
||||
onFeedback?: (feedback: FeedbackType) => void
|
||||
supportFeedback?: boolean
|
||||
}
|
||||
|
||||
const MetaSection: FC<MetaSectionProps> = ({
|
||||
showCharCount,
|
||||
charCount,
|
||||
t,
|
||||
shouldIndentForChild,
|
||||
isInWebApp,
|
||||
isInstalledApp,
|
||||
isResponding,
|
||||
isError,
|
||||
messageId,
|
||||
onOpenLogModal,
|
||||
moreLikeThis,
|
||||
onMoreLikeThis,
|
||||
disableMoreLikeThis,
|
||||
isShowTextToSpeech,
|
||||
textToSpeechVoice,
|
||||
canCopy,
|
||||
onCopy,
|
||||
onRetry,
|
||||
isWorkflow,
|
||||
onSave,
|
||||
feedback,
|
||||
onFeedback,
|
||||
supportFeedback,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(
|
||||
'system-xs-regular relative mt-1 h-4 px-4 text-text-quaternary',
|
||||
shouldIndentForChild && 'pl-10',
|
||||
)}>
|
||||
{showCharCount && <span>{charCount} {t('common.unit.char')}</span>}
|
||||
<div className='absolute bottom-1 right-2 flex items-center'>
|
||||
{!isInWebApp && !isInstalledApp && !isResponding && (
|
||||
<div className='ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm'>
|
||||
<ActionButton disabled={isError || !messageId} onClick={onOpenLogModal}>
|
||||
<RiFileList3Line className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
<div className='ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm'>
|
||||
{moreLikeThis && (
|
||||
<ActionButton state={disableMoreLikeThis ? ActionButtonState.Disabled : ActionButtonState.Default} disabled={disableMoreLikeThis} onClick={onMoreLikeThis}>
|
||||
<RiSparklingLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)}
|
||||
{isShowTextToSpeech && messageId && (
|
||||
<NewAudioButton
|
||||
id={messageId}
|
||||
voice={textToSpeechVoice}
|
||||
/>
|
||||
)}
|
||||
{canCopy && (
|
||||
<ActionButton disabled={isError || !messageId} onClick={onCopy}>
|
||||
<RiClipboardLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)}
|
||||
{isInWebApp && isError && (
|
||||
<ActionButton onClick={onRetry}>
|
||||
<RiReplay15Line className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)}
|
||||
{isInWebApp && !isWorkflow && (
|
||||
<ActionButton disabled={isError || !messageId} onClick={() => { onSave?.(messageId as string) }}>
|
||||
<RiBookmark3Line className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
{(supportFeedback || isInWebApp) && !isWorkflow && !isError && messageId && (
|
||||
<div className='ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm'>
|
||||
<FeedbackActions feedback={feedback} onFeedback={onFeedback} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MetaSection
|
||||
Reference in New Issue
Block a user