Compare commits

..

4 Commits

Author SHA1 Message Date
CodingOnStar
48cba768b1 test: enhance text generation and workflow callbacks tests
- Added comprehensive tests for the useTextGeneration hook, covering handleSend and handleStop functionalities, including file processing and error handling.
- Improved workflow callbacks tests to handle edge cases and ensure graceful handling of missing nodes and undefined tracing.
- Introduced new test cases for validating prompt variables and processing files in the text generation flow.
2026-02-06 18:03:41 +08:00
CodingOnStar
a985eb8725 Merge remote-tracking branch 'origin/main' into refactor/share 2026-02-06 17:32:21 +08:00
CodingOnStar
15d25f8876 feat: enhance text generation result components and add tests
- Removed unused ESLint suppressions from the configuration.
- Introduced new Result and Header components for better organization of text generation results.
- Added comprehensive tests for the new components and hooks to ensure functionality and reliability.
- Updated the useTextGeneration hook to streamline state management and improve performance.
2026-02-06 17:31:42 +08:00
CodingOnStar
cd4a4ed770 refactor: restructure text generation component and introduce new hooks
- Simplified the TextGeneration component by removing unused imports and state management.
- Introduced new hooks for managing app configuration and batch tasks.
- Added HeaderSection and ResultPanel components for better UI organization.
- Implemented tests for new components and hooks to ensure functionality.
- Updated types for improved type safety and clarity.
2026-02-06 16:51:01 +08:00
34 changed files with 4272 additions and 1540 deletions

View File

@@ -1696,18 +1696,13 @@ class DocumentService:
for document in documents
if document.data_source_type == "upload_file" and document.data_source_info_dict
]
if dataset.doc_form is not None:
batch_clean_document_task.delay(document_ids, dataset.id, dataset.doc_form, file_ids)
# Delete documents first, then dispatch cleanup task after commit
# to avoid deadlock between main transaction and async task
for document in documents:
db.session.delete(document)
db.session.commit()
# Dispatch cleanup task after commit to avoid lock contention
# Task cleans up segments, files, and vector indexes
if dataset.doc_form is not None:
batch_clean_document_task.delay(document_ids, dataset.id, dataset.doc_form, file_ids)
@staticmethod
def rename_document(dataset_id: str, document_id: str, name: str) -> Document:
assert isinstance(current_user, Account)

View File

@@ -0,0 +1,128 @@
import type { SavedMessage } from '@/models/debug'
import type { SiteInfo } from '@/models/share'
import { fireEvent, render, screen } from '@testing-library/react'
import { AccessMode } from '@/models/access-control'
import HeaderSection from './header-section'
// Mock menu-dropdown (sibling with external deps)
vi.mock('../menu-dropdown', () => ({
default: ({ hideLogout, data }: { hideLogout: boolean, data: SiteInfo }) => (
<div data-testid="menu-dropdown" data-hide-logout={String(hideLogout)}>{data.title}</div>
),
}))
const baseSiteInfo: SiteInfo = {
title: 'Test App',
icon_type: 'emoji',
icon: '🤖',
icon_background: '#eee',
icon_url: '',
description: 'A description',
default_language: 'en-US',
prompt_public: false,
copyright: '',
privacy_policy: '',
custom_disclaimer: '',
show_workflow_steps: false,
use_icon_as_answer_icon: false,
chat_color_theme: '',
}
const defaultProps = {
isPC: true,
isInstalledApp: false,
isWorkflow: false,
siteInfo: baseSiteInfo,
accessMode: AccessMode.PUBLIC,
savedMessages: [] as SavedMessage[],
currentTab: 'create',
onTabChange: vi.fn(),
}
describe('HeaderSection', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Basic rendering
describe('Rendering', () => {
it('should render app title and description', () => {
render(<HeaderSection {...defaultProps} />)
// MenuDropdown mock also renders title, so use getAllByText
expect(screen.getAllByText('Test App').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('A description')).toBeInTheDocument()
})
it('should not render description when empty', () => {
render(<HeaderSection {...defaultProps} siteInfo={{ ...baseSiteInfo, description: '' }} />)
expect(screen.queryByText('A description')).not.toBeInTheDocument()
})
})
// Tab rendering
describe('Tabs', () => {
it('should render create and batch tabs', () => {
render(<HeaderSection {...defaultProps} />)
expect(screen.getByText(/share\.generation\.tabs\.create/)).toBeInTheDocument()
expect(screen.getByText(/share\.generation\.tabs\.batch/)).toBeInTheDocument()
})
it('should render saved tab when not workflow', () => {
render(<HeaderSection {...defaultProps} isWorkflow={false} />)
expect(screen.getByText(/share\.generation\.tabs\.saved/)).toBeInTheDocument()
})
it('should hide saved tab when isWorkflow is true', () => {
render(<HeaderSection {...defaultProps} isWorkflow />)
expect(screen.queryByText(/share\.generation\.tabs\.saved/)).not.toBeInTheDocument()
})
it('should show badge count for saved messages', () => {
const messages: SavedMessage[] = [
{ id: '1', answer: 'a' } as SavedMessage,
{ id: '2', answer: 'b' } as SavedMessage,
]
render(<HeaderSection {...defaultProps} savedMessages={messages} />)
expect(screen.getByText('2')).toBeInTheDocument()
})
})
// Menu dropdown
describe('MenuDropdown', () => {
it('should pass hideLogout=true when accessMode is PUBLIC', () => {
render(<HeaderSection {...defaultProps} accessMode={AccessMode.PUBLIC} />)
expect(screen.getByTestId('menu-dropdown')).toHaveAttribute('data-hide-logout', 'true')
})
it('should pass hideLogout=true when isInstalledApp', () => {
render(<HeaderSection {...defaultProps} isInstalledApp={true} accessMode={AccessMode.SPECIFIC_GROUPS_MEMBERS} />)
expect(screen.getByTestId('menu-dropdown')).toHaveAttribute('data-hide-logout', 'true')
})
it('should pass hideLogout=false when not installed and accessMode is not PUBLIC', () => {
render(<HeaderSection {...defaultProps} isInstalledApp={false} accessMode={AccessMode.SPECIFIC_GROUPS_MEMBERS} />)
expect(screen.getByTestId('menu-dropdown')).toHaveAttribute('data-hide-logout', 'false')
})
})
// Tab change callback
describe('Interaction', () => {
it('should call onTabChange when a tab is clicked', () => {
const onTabChange = vi.fn()
render(<HeaderSection {...defaultProps} onTabChange={onTabChange} />)
fireEvent.click(screen.getByText(/share\.generation\.tabs\.batch/))
expect(onTabChange).toHaveBeenCalledWith('batch')
})
})
})

View File

@@ -0,0 +1,78 @@
import type { FC } from 'react'
import type { SavedMessage } from '@/models/debug'
import type { SiteInfo } from '@/models/share'
import { RiBookmark3Line } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Badge from '@/app/components/base/badge'
import TabHeader from '@/app/components/base/tab-header'
import { appDefaultIconBackground } from '@/config'
import { AccessMode } from '@/models/access-control'
import { cn } from '@/utils/classnames'
import MenuDropdown from '../menu-dropdown'
type HeaderSectionProps = {
isPC: boolean
isInstalledApp: boolean
isWorkflow: boolean
siteInfo: SiteInfo
accessMode: AccessMode
savedMessages: SavedMessage[]
currentTab: string
onTabChange: (tab: string) => void
}
const HeaderSection: FC<HeaderSectionProps> = ({
isPC,
isInstalledApp,
isWorkflow,
siteInfo,
accessMode,
savedMessages,
currentTab,
onTabChange,
}) => {
const { t } = useTranslation()
const tabItems = [
{ id: 'create', name: t('generation.tabs.create', { ns: 'share' }) },
{ id: 'batch', name: t('generation.tabs.batch', { ns: 'share' }) },
...(!isWorkflow
? [{
id: 'saved',
name: t('generation.tabs.saved', { ns: 'share' }),
isRight: true,
icon: <RiBookmark3Line className="h-4 w-4" />,
extra: savedMessages.length > 0
? <Badge className="ml-1">{savedMessages.length}</Badge>
: null,
}]
: []),
]
return (
<div className={cn('shrink-0 space-y-4 border-b border-divider-subtle', isPC ? 'bg-components-panel-bg p-8 pb-0' : 'p-4 pb-0')}>
<div className="flex items-center gap-3">
<AppIcon
size={isPC ? 'large' : 'small'}
iconType={siteInfo.icon_type}
icon={siteInfo.icon}
background={siteInfo.icon_background || appDefaultIconBackground}
imageUrl={siteInfo.icon_url}
/>
<div className="system-md-semibold grow truncate text-text-secondary">{siteInfo.title}</div>
<MenuDropdown hideLogout={isInstalledApp || accessMode === AccessMode.PUBLIC} data={siteInfo} />
</div>
{siteInfo.description && (
<div className="system-xs-regular text-text-tertiary">{siteInfo.description}</div>
)}
<TabHeader
items={tabItems}
value={currentTab}
onChange={onTabChange}
/>
</div>
)
}
export default HeaderSection

View File

@@ -0,0 +1,96 @@
import { render, screen } from '@testing-library/react'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { defaultSystemFeatures } from '@/types/feature'
import PoweredBy from './powered-by'
// Helper to override branding in system features while keeping other defaults
const setBranding = (branding: Partial<typeof defaultSystemFeatures.branding>) => {
useGlobalPublicStore.setState({
systemFeatures: {
...defaultSystemFeatures,
branding: { ...defaultSystemFeatures.branding, ...branding },
},
})
}
describe('PoweredBy', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Renders default Dify logo
describe('Default rendering', () => {
it('should render powered-by text', () => {
render(<PoweredBy isPC={true} resultExisted={false} customConfig={null} />)
expect(screen.getByText(/share\.chat\.poweredBy/)).toBeInTheDocument()
})
})
// Branding logo
describe('Custom branding', () => {
it('should render workspace logo when branding is enabled', () => {
setBranding({ enabled: true, workspace_logo: 'https://example.com/logo.png' })
render(<PoweredBy isPC={true} resultExisted={false} customConfig={null} />)
const img = screen.getByAltText('logo')
expect(img).toHaveAttribute('src', 'https://example.com/logo.png')
})
it('should render custom logo from customConfig', () => {
render(
<PoweredBy
isPC={true}
resultExisted={false}
customConfig={{ replace_webapp_logo: 'https://custom.com/logo.png' }}
/>,
)
const img = screen.getByAltText('logo')
expect(img).toHaveAttribute('src', 'https://custom.com/logo.png')
})
it('should prefer branding logo over custom config logo', () => {
setBranding({ enabled: true, workspace_logo: 'https://brand.com/logo.png' })
render(
<PoweredBy
isPC={true}
resultExisted={false}
customConfig={{ replace_webapp_logo: 'https://custom.com/logo.png' }}
/>,
)
const img = screen.getByAltText('logo')
expect(img).toHaveAttribute('src', 'https://brand.com/logo.png')
})
})
// Hidden when remove_webapp_brand
describe('Visibility', () => {
it('should return null when remove_webapp_brand is truthy', () => {
const { container } = render(
<PoweredBy
isPC={true}
resultExisted={false}
customConfig={{ remove_webapp_brand: true }}
/>,
)
expect(container.innerHTML).toBe('')
})
it('should render when remove_webapp_brand is falsy', () => {
const { container } = render(
<PoweredBy
isPC={true}
resultExisted={false}
customConfig={{ remove_webapp_brand: false }}
/>,
)
expect(container.innerHTML).not.toBe('')
})
})
})

View File

@@ -0,0 +1,40 @@
import type { FC } from 'react'
import type { CustomConfigValueType } from '@/models/share'
import { useTranslation } from 'react-i18next'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { cn } from '@/utils/classnames'
type PoweredByProps = {
isPC: boolean
resultExisted: boolean
customConfig: Record<string, CustomConfigValueType> | null
}
const PoweredBy: FC<PoweredByProps> = ({ isPC, resultExisted, customConfig }) => {
const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
if (customConfig?.remove_webapp_brand)
return null
const brandingLogo = systemFeatures.branding.enabled ? systemFeatures.branding.workspace_logo : undefined
const customLogo = customConfig?.replace_webapp_logo
const logoSrc = brandingLogo || (typeof customLogo === 'string' ? customLogo : undefined)
return (
<div className={cn(
'flex shrink-0 items-center gap-1.5 bg-components-panel-bg py-3',
isPC ? 'px-8' : 'px-4',
!isPC && resultExisted && 'rounded-b-2xl border-b-[0.5px] border-divider-regular',
)}
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
{logoSrc
? <img src={logoSrc} alt="logo" className="block h-5 w-auto" />
: <DifyLogo size="small" />}
</div>
)
}
export default PoweredBy

View File

@@ -0,0 +1,157 @@
import { fireEvent, render, screen } from '@testing-library/react'
import ResultPanel from './result-panel'
// Mock ResDownload (sibling dep with CSV logic)
vi.mock('../run-batch/res-download', () => ({
default: ({ values }: { values: Record<string, string>[] }) => (
<button data-testid="res-download">
Download (
{values.length}
)
</button>
),
}))
const defaultProps = {
isPC: true,
isShowResultPanel: false,
isCallBatchAPI: false,
totalTasks: 0,
successCount: 0,
failedCount: 0,
noPendingTask: true,
exportRes: [] as Record<string, string>[],
onRetryFailed: vi.fn(),
}
describe('ResultPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Renders children
describe('Rendering', () => {
it('should render children content', () => {
render(
<ResultPanel {...defaultProps}>
<div>Result content</div>
</ResultPanel>,
)
expect(screen.getByText('Result content')).toBeInTheDocument()
})
})
// Batch header
describe('Batch mode header', () => {
it('should show execution count when isCallBatchAPI is true', () => {
render(
<ResultPanel {...defaultProps} isCallBatchAPI={true} totalTasks={5}>
<div />
</ResultPanel>,
)
expect(screen.getByText(/share\.generation\.executions/)).toBeInTheDocument()
})
it('should not show execution header when not in batch mode', () => {
render(
<ResultPanel {...defaultProps} isCallBatchAPI={false}>
<div />
</ResultPanel>,
)
expect(screen.queryByText(/share\.generation\.executions/)).not.toBeInTheDocument()
})
it('should show download button when there are successful tasks', () => {
render(
<ResultPanel {...defaultProps} isCallBatchAPI={true} successCount={3} exportRes={[{ a: 'b' }]}>
<div />
</ResultPanel>,
)
expect(screen.getByTestId('res-download')).toBeInTheDocument()
})
it('should not show download button when no successful tasks', () => {
render(
<ResultPanel {...defaultProps} isCallBatchAPI={true} successCount={0}>
<div />
</ResultPanel>,
)
expect(screen.queryByTestId('res-download')).not.toBeInTheDocument()
})
})
// Loading indicator for pending tasks
describe('Pending tasks', () => {
it('should show loading area when there are pending tasks', () => {
const { container } = render(
<ResultPanel {...defaultProps} noPendingTask={false}>
<div />
</ResultPanel>,
)
expect(container.querySelector('.mt-4')).toBeInTheDocument()
})
it('should not show loading when all tasks are done', () => {
const { container } = render(
<ResultPanel {...defaultProps} noPendingTask={true}>
<div />
</ResultPanel>,
)
expect(container.querySelector('.mt-4')).not.toBeInTheDocument()
})
})
// Failed tasks retry bar
describe('Failed tasks retry', () => {
it('should show retry bar when batch has failed tasks', () => {
render(
<ResultPanel {...defaultProps} isCallBatchAPI={true} failedCount={2}>
<div />
</ResultPanel>,
)
expect(screen.getByText(/share\.generation\.batchFailed\.info/)).toBeInTheDocument()
expect(screen.getByText(/share\.generation\.batchFailed\.retry/)).toBeInTheDocument()
})
it('should call onRetryFailed when retry is clicked', () => {
const onRetry = vi.fn()
render(
<ResultPanel {...defaultProps} isCallBatchAPI={true} failedCount={1} onRetryFailed={onRetry}>
<div />
</ResultPanel>,
)
fireEvent.click(screen.getByText(/share\.generation\.batchFailed\.retry/))
expect(onRetry).toHaveBeenCalledTimes(1)
})
it('should not show retry bar when no failed tasks', () => {
render(
<ResultPanel {...defaultProps} isCallBatchAPI={true} failedCount={0}>
<div />
</ResultPanel>,
)
expect(screen.queryByText(/share\.generation\.batchFailed\.retry/)).not.toBeInTheDocument()
})
it('should not show retry bar when not in batch mode even with failed count', () => {
render(
<ResultPanel {...defaultProps} isCallBatchAPI={false} failedCount={3}>
<div />
</ResultPanel>,
)
expect(screen.queryByText(/share\.generation\.batchFailed\.retry/)).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,94 @@
import type { FC, ReactNode } from 'react'
import { RiErrorWarningFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { cn } from '@/utils/classnames'
import ResDownload from '../run-batch/res-download'
type ResultPanelProps = {
isPC: boolean
isShowResultPanel: boolean
isCallBatchAPI: boolean
totalTasks: number
successCount: number
failedCount: number
noPendingTask: boolean
exportRes: Record<string, string>[]
onRetryFailed: () => void
children: ReactNode
}
const ResultPanel: FC<ResultPanelProps> = ({
isPC,
isShowResultPanel,
isCallBatchAPI,
totalTasks,
successCount,
failedCount,
noPendingTask,
exportRes,
onRetryFailed,
children,
}) => {
const { t } = useTranslation()
return (
<div
className={cn(
'relative flex h-full flex-col',
!isPC && 'h-[calc(100vh_-_36px)] rounded-t-2xl shadow-lg backdrop-blur-sm',
!isPC
? isShowResultPanel
? 'bg-background-default-burn'
: 'border-t-[0.5px] border-divider-regular bg-components-panel-bg'
: 'bg-chatbot-bg',
)}
>
{isCallBatchAPI && (
<div className={cn(
'flex shrink-0 items-center justify-between px-14 pb-2 pt-9',
!isPC && 'px-4 pb-1 pt-3',
)}
>
<div className="system-md-semibold-uppercase text-text-primary">
{t('generation.executions', { ns: 'share', num: totalTasks })}
</div>
{successCount > 0 && (
<ResDownload isMobile={!isPC} values={exportRes} />
)}
</div>
)}
<div className={cn(
'flex h-0 grow flex-col overflow-y-auto',
isPC && 'px-14 py-8',
isPC && isCallBatchAPI && 'pt-0',
!isPC && 'p-0 pb-2',
)}
>
{children}
{!noPendingTask && (
<div className="mt-4">
<Loading type="area" />
</div>
)}
</div>
{isCallBatchAPI && failedCount > 0 && (
<div className="absolute bottom-6 left-1/2 z-10 flex -translate-x-1/2 items-center gap-2 rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg backdrop-blur-sm">
<RiErrorWarningFill className="h-4 w-4 text-text-destructive" />
<div className="system-sm-medium text-text-secondary">
{t('generation.batchFailed.info', { ns: 'share', num: failedCount })}
</div>
<div className="h-3.5 w-px bg-divider-regular"></div>
<div
onClick={onRetryFailed}
className="system-sm-semibold-uppercase cursor-pointer text-text-accent"
>
{t('generation.batchFailed.retry', { ns: 'share' })}
</div>
</div>
)}
</div>
)
}
export default ResultPanel

View File

@@ -0,0 +1,210 @@
import type { ChatConfig } from '@/app/components/base/chat/types'
import type { Locale } from '@/i18n-config/language'
import type { SiteInfo } from '@/models/share'
import { renderHook } from '@testing-library/react'
import { useWebAppStore } from '@/context/web-app-context'
import { PromptMode } from '@/models/debug'
import { useAppConfig } from './use-app-config'
// Mock changeLanguage side-effect
const mockChangeLanguage = vi.fn()
vi.mock('@/i18n-config/client', () => ({
changeLanguage: (...args: unknown[]) => mockChangeLanguage(...args),
}))
const baseSiteInfo: SiteInfo = {
title: 'My App',
icon_type: 'emoji',
icon: '🤖',
icon_background: '#fff',
icon_url: '',
description: 'A test app',
default_language: 'en-US' as Locale,
prompt_public: false,
copyright: '',
privacy_policy: '',
custom_disclaimer: '',
show_workflow_steps: false,
use_icon_as_answer_icon: false,
chat_color_theme: '',
}
const baseAppParams = {
user_input_form: [
{ 'text-input': { label: 'Name', variable: 'name', required: true, default: '', max_length: 100, hide: false } },
],
more_like_this: { enabled: true },
text_to_speech: { enabled: false },
file_upload: {
allowed_file_upload_methods: ['local_file'],
allowed_file_types: [],
max_length: 10,
number_limits: 3,
},
system_parameters: {
audio_file_size_limit: 50,
file_size_limit: 15,
image_file_size_limit: 10,
video_file_size_limit: 100,
workflow_file_upload_limit: 10,
},
opening_statement: '',
pre_prompt: '',
prompt_type: PromptMode.simple,
suggested_questions_after_answer: { enabled: false },
speech_to_text: { enabled: false },
retriever_resource: { enabled: false },
sensitive_word_avoidance: { enabled: false },
agent_mode: { enabled: false, tools: [] },
dataset_configs: { datasets: { datasets: [] }, retrieval_model: 'single' },
} as unknown as ChatConfig
describe('useAppConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Default state when store has no data
describe('Default state', () => {
it('should return not-ready state when store is empty', () => {
useWebAppStore.setState({ appInfo: null, appParams: null })
const { result } = renderHook(() => useAppConfig())
expect(result.current.appId).toBe('')
expect(result.current.siteInfo).toBeNull()
expect(result.current.promptConfig).toBeNull()
expect(result.current.isReady).toBe(false)
})
})
// Deriving config from store data
describe('Config derivation', () => {
it('should derive appId and siteInfo from appInfo', () => {
useWebAppStore.setState({
appInfo: { app_id: 'app-123', site: baseSiteInfo, custom_config: null },
appParams: baseAppParams,
})
const { result } = renderHook(() => useAppConfig())
expect(result.current.appId).toBe('app-123')
expect(result.current.siteInfo?.title).toBe('My App')
})
it('should derive promptConfig with prompt_variables from user_input_form', () => {
useWebAppStore.setState({
appInfo: { app_id: 'app-1', site: baseSiteInfo, custom_config: null },
appParams: baseAppParams,
})
const { result } = renderHook(() => useAppConfig())
expect(result.current.promptConfig).not.toBeNull()
expect(result.current.promptConfig!.prompt_variables).toHaveLength(1)
expect(result.current.promptConfig!.prompt_variables[0].key).toBe('name')
})
it('should derive moreLikeThisConfig and textToSpeechConfig from appParams', () => {
useWebAppStore.setState({
appInfo: { app_id: 'app-1', site: baseSiteInfo, custom_config: null },
appParams: baseAppParams,
})
const { result } = renderHook(() => useAppConfig())
expect(result.current.moreLikeThisConfig).toEqual({ enabled: true })
expect(result.current.textToSpeechConfig).toEqual({ enabled: false })
})
it('should derive visionConfig from file_upload and system_parameters', () => {
useWebAppStore.setState({
appInfo: { app_id: 'app-1', site: baseSiteInfo, custom_config: null },
appParams: baseAppParams,
})
const { result } = renderHook(() => useAppConfig())
expect(result.current.visionConfig.transfer_methods).toEqual(['local_file'])
expect(result.current.visionConfig.image_file_size_limit).toBe(10)
})
it('should return default visionConfig when appParams is null', () => {
useWebAppStore.setState({
appInfo: { app_id: 'app-1', site: baseSiteInfo, custom_config: null },
appParams: null,
})
const { result } = renderHook(() => useAppConfig())
expect(result.current.visionConfig.enabled).toBe(false)
expect(result.current.visionConfig.number_limits).toBe(2)
})
it('should return customConfig from appInfo', () => {
useWebAppStore.setState({
appInfo: { app_id: 'app-1', site: baseSiteInfo, custom_config: { remove_webapp_brand: true } },
appParams: baseAppParams,
})
const { result } = renderHook(() => useAppConfig())
expect(result.current.customConfig).toEqual({ remove_webapp_brand: true })
})
})
// Readiness condition
describe('isReady', () => {
it('should be true when appId, siteInfo and promptConfig are all present', () => {
useWebAppStore.setState({
appInfo: { app_id: 'app-1', site: baseSiteInfo, custom_config: null },
appParams: baseAppParams,
})
const { result } = renderHook(() => useAppConfig())
expect(result.current.isReady).toBe(true)
})
it('should be false when appParams is missing (no promptConfig)', () => {
useWebAppStore.setState({
appInfo: { app_id: 'app-1', site: baseSiteInfo, custom_config: null },
appParams: null,
})
const { result } = renderHook(() => useAppConfig())
expect(result.current.isReady).toBe(false)
})
it('should be false when appInfo is missing (no appId or siteInfo)', () => {
useWebAppStore.setState({ appInfo: null, appParams: baseAppParams })
const { result } = renderHook(() => useAppConfig())
expect(result.current.isReady).toBe(false)
})
})
// Language sync side-effect
describe('Language sync', () => {
it('should call changeLanguage when siteInfo has default_language', () => {
useWebAppStore.setState({
appInfo: { app_id: 'app-1', site: baseSiteInfo, custom_config: null },
appParams: baseAppParams,
})
renderHook(() => useAppConfig())
expect(mockChangeLanguage).toHaveBeenCalledWith('en-US')
})
it('should not call changeLanguage when siteInfo is null', () => {
useWebAppStore.setState({ appInfo: null, appParams: null })
renderHook(() => useAppConfig())
expect(mockChangeLanguage).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,84 @@
import type { AccessMode } from '@/models/access-control'
import type {
MoreLikeThisConfig,
PromptConfig,
TextToSpeechConfig,
} from '@/models/debug'
import type { CustomConfigValueType, SiteInfo } from '@/models/share'
import type { VisionSettings } from '@/types/app'
import { useEffect, useMemo } from 'react'
import { useWebAppStore } from '@/context/web-app-context'
import { changeLanguage } from '@/i18n-config/client'
import { Resolution, TransferMethod } from '@/types/app'
import { userInputsFormToPromptVariables } from '@/utils/model-config'
const DEFAULT_VISION_CONFIG: VisionSettings = {
enabled: false,
number_limits: 2,
detail: Resolution.low,
transfer_methods: [TransferMethod.local_file],
}
export type AppConfig = {
appId: string
siteInfo: SiteInfo | null
customConfig: Record<string, CustomConfigValueType> | null
promptConfig: PromptConfig | null
moreLikeThisConfig: MoreLikeThisConfig | null
textToSpeechConfig: TextToSpeechConfig | null
visionConfig: VisionSettings
accessMode: AccessMode
isReady: boolean
}
export function useAppConfig(): AppConfig {
const appData = useWebAppStore(s => s.appInfo)
const appParams = useWebAppStore(s => s.appParams)
const accessMode = useWebAppStore(s => s.webAppAccessMode)
const appId = appData?.app_id ?? ''
const siteInfo = (appData?.site as SiteInfo) ?? null
const customConfig = appData?.custom_config ?? null
const promptConfig = useMemo<PromptConfig | null>(() => {
if (!appParams)
return null
const prompt_variables = userInputsFormToPromptVariables(appParams.user_input_form)
return { prompt_template: '', prompt_variables } as PromptConfig
}, [appParams])
const moreLikeThisConfig: MoreLikeThisConfig | null = appParams?.more_like_this ?? null
const textToSpeechConfig: TextToSpeechConfig | null = appParams?.text_to_speech ?? null
const visionConfig = useMemo<VisionSettings>(() => {
if (!appParams)
return DEFAULT_VISION_CONFIG
const { file_upload, system_parameters } = appParams
return {
...file_upload,
transfer_methods: file_upload?.allowed_file_upload_methods || file_upload?.allowed_upload_methods || [],
image_file_size_limit: system_parameters?.image_file_size_limit,
fileUploadConfig: system_parameters,
} as unknown as VisionSettings
}, [appParams])
// Sync language when site info changes
useEffect(() => {
if (siteInfo?.default_language)
changeLanguage(siteInfo.default_language)
}, [siteInfo?.default_language])
const isReady = !!(appId && siteInfo && promptConfig)
return {
appId,
siteInfo,
customConfig,
promptConfig,
moreLikeThisConfig,
textToSpeechConfig,
visionConfig,
accessMode,
isReady,
}
}

View File

@@ -0,0 +1,299 @@
import type { PromptConfig } from '@/models/debug'
import { act, renderHook } from '@testing-library/react'
import { TaskStatus } from '../types'
import { useBatchTasks } from './use-batch-tasks'
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
}))
const createPromptConfig = (overrides?: Partial<PromptConfig>): PromptConfig => ({
prompt_template: '',
prompt_variables: [
{ key: 'name', name: 'Name', type: 'string', required: true, max_length: 100 },
{ key: 'age', name: 'Age', type: 'string', required: false, max_length: 10 },
] as PromptConfig['prompt_variables'],
...overrides,
})
// Build a valid CSV data matrix: [header, ...rows]
const buildCsvData = (rows: string[][]): string[][] => [
['Name', 'Age'],
...rows,
]
describe('useBatchTasks', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Initial state
describe('Initial state', () => {
it('should start with empty task list and batch mode off', () => {
const { result } = renderHook(() => useBatchTasks(createPromptConfig()))
expect(result.current.isCallBatchAPI).toBe(false)
expect(result.current.allTaskList).toEqual([])
expect(result.current.noPendingTask).toBe(true)
expect(result.current.allTasksRun).toBe(true)
})
})
// Batch validation via startBatchRun
describe('startBatchRun validation', () => {
it('should reject empty data', () => {
const { result } = renderHook(() => useBatchTasks(createPromptConfig()))
let ok = false
act(() => {
ok = result.current.startBatchRun([])
})
expect(ok).toBe(false)
expect(result.current.isCallBatchAPI).toBe(false)
})
it('should reject data with mismatched header', () => {
const { result } = renderHook(() => useBatchTasks(createPromptConfig()))
const data = [['Wrong', 'Header'], ['a', 'b']]
let ok = false
act(() => {
ok = result.current.startBatchRun(data)
})
expect(ok).toBe(false)
})
it('should reject data with no payload rows (header only)', () => {
const { result } = renderHook(() => useBatchTasks(createPromptConfig()))
const data = [['Name', 'Age']]
let ok = false
act(() => {
ok = result.current.startBatchRun(data)
})
expect(ok).toBe(false)
})
it('should reject when required field is empty', () => {
const { result } = renderHook(() => useBatchTasks(createPromptConfig()))
const data = buildCsvData([['', '25']])
let ok = false
act(() => {
ok = result.current.startBatchRun(data)
})
expect(ok).toBe(false)
})
it('should reject when required field exceeds max_length', () => {
const { result } = renderHook(() => useBatchTasks(createPromptConfig()))
const longName = 'a'.repeat(101)
const data = buildCsvData([[longName, '25']])
let ok = false
act(() => {
ok = result.current.startBatchRun(data)
})
expect(ok).toBe(false)
})
})
// Successful batch run
describe('startBatchRun success', () => {
it('should create tasks and enable batch mode', () => {
const { result } = renderHook(() => useBatchTasks(createPromptConfig()))
const data = buildCsvData([['Alice', '30'], ['Bob', '25']])
let ok = false
act(() => {
ok = result.current.startBatchRun(data)
})
expect(ok).toBe(true)
expect(result.current.isCallBatchAPI).toBe(true)
expect(result.current.allTaskList).toHaveLength(2)
expect(result.current.allTaskList[0].params.inputs.name).toBe('Alice')
expect(result.current.allTaskList[1].params.inputs.name).toBe('Bob')
})
it('should set first tasks to running status (limited by BATCH_CONCURRENCY)', () => {
const { result } = renderHook(() => useBatchTasks(createPromptConfig()))
const data = buildCsvData([['Alice', '30'], ['Bob', '25']])
act(() => {
result.current.startBatchRun(data)
})
// Both should be running since 2 < BATCH_CONCURRENCY (5)
expect(result.current.allTaskList[0].status).toBe(TaskStatus.running)
expect(result.current.allTaskList[1].status).toBe(TaskStatus.running)
})
it('should set excess tasks to pending when exceeding BATCH_CONCURRENCY', () => {
const { result } = renderHook(() => useBatchTasks(createPromptConfig()))
// Create 7 tasks (BATCH_CONCURRENCY=5, so 2 should be pending)
const rows = Array.from({ length: 7 }, (_, i) => [`User${i}`, `${20 + i}`])
const data = buildCsvData(rows)
act(() => {
result.current.startBatchRun(data)
})
const running = result.current.allTaskList.filter(t => t.status === TaskStatus.running)
const pending = result.current.allTaskList.filter(t => t.status === TaskStatus.pending)
expect(running).toHaveLength(5)
expect(pending).toHaveLength(2)
})
})
// Task completion handling
describe('handleCompleted', () => {
it('should mark task as completed on success', () => {
const { result } = renderHook(() => useBatchTasks(createPromptConfig()))
act(() => {
result.current.startBatchRun(buildCsvData([['Alice', '30']]))
})
act(() => {
result.current.handleCompleted('result text', 1, true)
})
expect(result.current.allTaskList[0].status).toBe(TaskStatus.completed)
})
it('should mark task as failed on failure', () => {
const { result } = renderHook(() => useBatchTasks(createPromptConfig()))
act(() => {
result.current.startBatchRun(buildCsvData([['Alice', '30']]))
})
act(() => {
result.current.handleCompleted('', 1, false)
})
expect(result.current.allTaskList[0].status).toBe(TaskStatus.failed)
expect(result.current.allFailedTaskList).toHaveLength(1)
})
it('should promote pending tasks to running when group completes', () => {
const { result } = renderHook(() => useBatchTasks(createPromptConfig()))
// 7 tasks: first 5 running, last 2 pending
const rows = Array.from({ length: 7 }, (_, i) => [`User${i}`, `${20 + i}`])
act(() => {
result.current.startBatchRun(buildCsvData(rows))
})
// Complete all 5 running tasks
for (let i = 1; i <= 5; i++) {
act(() => {
result.current.handleCompleted(`res${i}`, i, true)
})
}
// Tasks 6 and 7 should now be running
expect(result.current.allTaskList[5].status).toBe(TaskStatus.running)
expect(result.current.allTaskList[6].status).toBe(TaskStatus.running)
})
})
// Derived task lists
describe('Derived lists', () => {
it('should compute showTaskList excluding pending tasks', () => {
const { result } = renderHook(() => useBatchTasks(createPromptConfig()))
const rows = Array.from({ length: 7 }, (_, i) => [`User${i}`, `${i}`])
act(() => {
result.current.startBatchRun(buildCsvData(rows))
})
expect(result.current.showTaskList).toHaveLength(5) // 5 running
expect(result.current.noPendingTask).toBe(false)
})
it('should compute allTasksRun when all tasks completed or failed', () => {
const { result } = renderHook(() => useBatchTasks(createPromptConfig()))
act(() => {
result.current.startBatchRun(buildCsvData([['Alice', '30'], ['Bob', '25']]))
})
expect(result.current.allTasksRun).toBe(false)
act(() => {
result.current.handleCompleted('res1', 1, true)
})
act(() => {
result.current.handleCompleted('', 2, false)
})
expect(result.current.allTasksRun).toBe(true)
expect(result.current.allSuccessTaskList).toHaveLength(1)
expect(result.current.allFailedTaskList).toHaveLength(1)
})
})
// Clear state
describe('clearBatchState', () => {
it('should reset batch mode and task list', () => {
const { result } = renderHook(() => useBatchTasks(createPromptConfig()))
act(() => {
result.current.startBatchRun(buildCsvData([['Alice', '30']]))
})
expect(result.current.isCallBatchAPI).toBe(true)
act(() => {
result.current.clearBatchState()
})
expect(result.current.isCallBatchAPI).toBe(false)
expect(result.current.allTaskList).toEqual([])
})
})
// Export results
describe('exportRes', () => {
it('should format export data with variable names as keys', () => {
const { result } = renderHook(() => useBatchTasks(createPromptConfig()))
act(() => {
result.current.startBatchRun(buildCsvData([['Alice', '30']]))
})
act(() => {
result.current.handleCompleted('Generated text', 1, true)
})
const exported = result.current.exportRes
expect(exported).toHaveLength(1)
expect(exported[0].Name).toBe('Alice')
expect(exported[0].Age).toBe('30')
})
it('should use empty string for missing optional inputs', () => {
const { result } = renderHook(() => useBatchTasks(createPromptConfig()))
act(() => {
result.current.startBatchRun(buildCsvData([['Alice', '']]))
})
act(() => {
result.current.handleCompleted('res', 1, true)
})
expect(result.current.exportRes[0].Age).toBe('')
})
})
// Retry failed tasks
describe('handleRetryAllFailedTask', () => {
it('should update controlRetry timestamp', () => {
const { result } = renderHook(() => useBatchTasks(createPromptConfig()))
const before = result.current.controlRetry
act(() => {
result.current.handleRetryAllFailedTask()
})
expect(result.current.controlRetry).toBeGreaterThan(before)
})
})
})

View File

@@ -0,0 +1,219 @@
import type { TFunction } from 'i18next'
import type { Task } from '../types'
import type { PromptConfig } from '@/models/debug'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { BATCH_CONCURRENCY } from '@/config'
import { TaskStatus } from '../types'
function validateBatchData(
data: string[][],
promptVariables: PromptConfig['prompt_variables'],
t: TFunction,
): string | null {
if (!data?.length)
return t('generation.errorMsg.empty', { ns: 'share' })
// Validate header matches prompt variables
const header = data[0]
if (promptVariables.some((v, i) => v.name !== header[i]))
return t('generation.errorMsg.fileStructNotMatch', { ns: 'share' })
const rows = data.slice(1)
if (!rows.length)
return t('generation.errorMsg.atLeastOne', { ns: 'share' })
// Detect non-consecutive empty lines (empty rows in the middle of data)
const emptyIndexes = rows
.map((row, i) => row.every(c => c === '') ? i : -1)
.filter(i => i >= 0)
if (emptyIndexes.length > 0) {
let prev = emptyIndexes[0] - 1
for (const idx of emptyIndexes) {
if (prev + 1 !== idx)
return t('generation.errorMsg.emptyLine', { ns: 'share', rowIndex: prev + 2 })
prev = idx
}
}
// Remove trailing empty rows and re-check
const nonEmptyRows = rows.filter(row => !row.every(c => c === ''))
if (!nonEmptyRows.length)
return t('generation.errorMsg.atLeastOne', { ns: 'share' })
// Validate individual row values
for (let r = 0; r < nonEmptyRows.length; r++) {
const row = nonEmptyRows[r]
for (let v = 0; v < promptVariables.length; v++) {
const varItem = promptVariables[v]
if (varItem.type === 'string' && varItem.max_length && row[v].length > varItem.max_length) {
return t('generation.errorMsg.moreThanMaxLengthLine', {
ns: 'share',
rowIndex: r + 2,
varName: varItem.name,
maxLength: varItem.max_length,
})
}
if (varItem.required && row[v].trim() === '') {
return t('generation.errorMsg.invalidLine', {
ns: 'share',
rowIndex: r + 2,
varName: varItem.name,
})
}
}
}
return null
}
export function useBatchTasks(promptConfig: PromptConfig | null) {
const { t } = useTranslation()
const [isCallBatchAPI, setIsCallBatchAPI] = useState(false)
const [controlRetry, setControlRetry] = useState(0)
// Task list with ref for accessing latest value in async callbacks
const [allTaskList, doSetAllTaskList] = useState<Task[]>([])
const allTaskListRef = useRef<Task[]>([])
const setAllTaskList = useCallback((tasks: Task[]) => {
doSetAllTaskList(tasks)
allTaskListRef.current = tasks
}, [])
// Batch completion results stored in ref (no re-render needed on each update)
const batchCompletionResRef = useRef<Record<string, string>>({})
const currGroupNumRef = useRef(0)
// Derived task lists
const pendingTaskList = allTaskList.filter(task => task.status === TaskStatus.pending)
const noPendingTask = pendingTaskList.length === 0
const showTaskList = allTaskList.filter(task => task.status !== TaskStatus.pending)
const allSuccessTaskList = allTaskList.filter(task => task.status === TaskStatus.completed)
const allFailedTaskList = allTaskList.filter(task => task.status === TaskStatus.failed)
const allTasksFinished = allTaskList.every(task => task.status === TaskStatus.completed)
const allTasksRun = allTaskList.every(task =>
[TaskStatus.completed, TaskStatus.failed].includes(task.status),
)
// Export-ready results for CSV download
const exportRes = allTaskList.map((task) => {
const completionRes = batchCompletionResRef.current
const res: Record<string, string> = {}
const { inputs } = task.params
promptConfig?.prompt_variables.forEach((v) => {
res[v.name] = inputs[v.key] ?? ''
})
let result = completionRes[task.id]
if (typeof result === 'object')
result = JSON.stringify(result)
res[t('generation.completionResult', { ns: 'share' })] = result
return res
})
// Clear batch state (used when switching to single-run mode)
const clearBatchState = useCallback(() => {
setIsCallBatchAPI(false)
setAllTaskList([])
}, [setAllTaskList])
// Attempt to start a batch run. Returns true on success, false on validation failure.
const startBatchRun = useCallback((data: string[][]): boolean => {
const error = validateBatchData(data, promptConfig?.prompt_variables ?? [], t)
if (error) {
Toast.notify({ type: 'error', message: error })
return false
}
if (!allTasksFinished) {
Toast.notify({ type: 'info', message: t('errorMessage.waitForBatchResponse', { ns: 'appDebug' }) })
return false
}
const payloadData = data.filter(row => !row.every(c => c === '')).slice(1)
const varLen = promptConfig?.prompt_variables.length ?? 0
const tasks: Task[] = payloadData.map((item, i) => {
const inputs: Record<string, string | undefined> = {}
if (varLen > 0) {
item.slice(0, varLen).forEach((input, index) => {
const varSchema = promptConfig?.prompt_variables[index]
const key = varSchema?.key as string
if (!input)
inputs[key] = (varSchema?.type === 'string' || varSchema?.type === 'paragraph') ? '' : undefined
else
inputs[key] = input
})
}
return {
id: i + 1,
status: i < BATCH_CONCURRENCY ? TaskStatus.running : TaskStatus.pending,
params: { inputs },
}
})
setAllTaskList(tasks)
currGroupNumRef.current = 0
batchCompletionResRef.current = {}
setIsCallBatchAPI(true)
return true
}, [allTasksFinished, promptConfig?.prompt_variables, setAllTaskList, t])
// Callback invoked when a single task completes; manages group concurrency.
const handleCompleted = useCallback((completionRes: string, taskId?: number, isSuccess?: boolean) => {
const latestTasks = allTaskListRef.current
const latestCompletionRes = batchCompletionResRef.current
const pending = latestTasks.filter(task => task.status === TaskStatus.pending)
const doneCount = 1 + latestTasks.filter(task =>
[TaskStatus.completed, TaskStatus.failed].includes(task.status),
).length
const shouldAddNextGroup
= currGroupNumRef.current !== doneCount
&& pending.length > 0
&& (doneCount % BATCH_CONCURRENCY === 0 || latestTasks.length - doneCount < BATCH_CONCURRENCY)
if (shouldAddNextGroup)
currGroupNumRef.current = doneCount
const nextPendingIds = shouldAddNextGroup
? pending.slice(0, BATCH_CONCURRENCY).map(t => t.id)
: []
const updatedTasks = latestTasks.map((item) => {
if (item.id === taskId)
return { ...item, status: isSuccess ? TaskStatus.completed : TaskStatus.failed }
if (shouldAddNextGroup && nextPendingIds.includes(item.id))
return { ...item, status: TaskStatus.running }
return item
})
setAllTaskList(updatedTasks)
if (taskId) {
batchCompletionResRef.current = {
...latestCompletionRes,
[`${taskId}`]: completionRes,
}
}
}, [setAllTaskList])
const handleRetryAllFailedTask = useCallback(() => {
setControlRetry(Date.now())
}, [])
return {
isCallBatchAPI,
controlRetry,
allTaskList,
showTaskList,
noPendingTask,
allSuccessTaskList,
allFailedTaskList,
allTasksRun,
exportRes,
clearBatchState,
startBatchRun,
handleCompleted,
handleRetryAllFailedTask,
}
}

View File

@@ -0,0 +1,141 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import * as React from 'react'
import { AppSourceType } from '@/service/share'
import {
useInvalidateSavedMessages,
useRemoveMessageMutation,
useSavedMessages,
useSaveMessageMutation,
} from './use-saved-messages'
// Mock service layer (preserve enum exports)
vi.mock('@/service/share', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>()
return {
...actual,
fetchSavedMessage: vi.fn(),
saveMessage: vi.fn(),
removeMessage: vi.fn(),
}
})
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
}))
// Get mocked functions for assertion
const shareModule = await import('@/service/share')
const mockFetchSavedMessage = shareModule.fetchSavedMessage as ReturnType<typeof vi.fn>
const mockSaveMessage = shareModule.saveMessage as ReturnType<typeof vi.fn>
const mockRemoveMessage = shareModule.removeMessage as ReturnType<typeof vi.fn>
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const APP_SOURCE = AppSourceType.webApp
const APP_ID = 'test-app-id'
describe('useSavedMessages', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Fetching saved messages
describe('Query behavior', () => {
it('should fetch saved messages when enabled and appId present', async () => {
mockFetchSavedMessage.mockResolvedValue({ data: [{ id: 'm1', answer: 'Hello' }] })
const { result } = renderHook(
() => useSavedMessages(APP_SOURCE, APP_ID, true),
{ wrapper: createWrapper() },
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual([{ id: 'm1', answer: 'Hello' }])
expect(mockFetchSavedMessage).toHaveBeenCalledWith(APP_SOURCE, APP_ID)
})
it('should not fetch when disabled', () => {
const { result } = renderHook(
() => useSavedMessages(APP_SOURCE, APP_ID, false),
{ wrapper: createWrapper() },
)
expect(result.current.fetchStatus).toBe('idle')
expect(mockFetchSavedMessage).not.toHaveBeenCalled()
})
it('should not fetch when appId is empty', () => {
const { result } = renderHook(
() => useSavedMessages(APP_SOURCE, '', true),
{ wrapper: createWrapper() },
)
expect(result.current.fetchStatus).toBe('idle')
expect(mockFetchSavedMessage).not.toHaveBeenCalled()
})
})
})
describe('useSaveMessageMutation', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should call saveMessage service on mutate', async () => {
mockSaveMessage.mockResolvedValue({})
const { result } = renderHook(
() => useSaveMessageMutation(APP_SOURCE, APP_ID),
{ wrapper: createWrapper() },
)
result.current.mutate('msg-1')
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockSaveMessage).toHaveBeenCalledWith('msg-1', APP_SOURCE, APP_ID)
})
})
describe('useRemoveMessageMutation', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should call removeMessage service on mutate', async () => {
mockRemoveMessage.mockResolvedValue({})
const { result } = renderHook(
() => useRemoveMessageMutation(APP_SOURCE, APP_ID),
{ wrapper: createWrapper() },
)
result.current.mutate('msg-2')
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockRemoveMessage).toHaveBeenCalledWith('msg-2', APP_SOURCE, APP_ID)
})
})
describe('useInvalidateSavedMessages', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return a callable invalidation function', () => {
const { result } = renderHook(
() => useInvalidateSavedMessages(APP_SOURCE, APP_ID),
{ wrapper: createWrapper() },
)
expect(typeof result.current).toBe('function')
expect(() => result.current()).not.toThrow()
})
})

View File

@@ -0,0 +1,79 @@
import type { SavedMessage } from '@/models/debug'
import type { AppSourceType } from '@/service/share'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import {
fetchSavedMessage,
removeMessage,
saveMessage,
} from '@/service/share'
const NAME_SPACE = 'text-generation'
export const savedMessagesQueryKeys = {
all: (appSourceType: AppSourceType, appId: string) =>
[NAME_SPACE, 'savedMessages', appSourceType, appId] as const,
}
export function useSavedMessages(
appSourceType: AppSourceType,
appId: string,
enabled = true,
) {
return useQuery<SavedMessage[]>({
queryKey: savedMessagesQueryKeys.all(appSourceType, appId),
queryFn: async () => {
const res = await fetchSavedMessage(appSourceType, appId) as { data: SavedMessage[] }
return res.data
},
enabled: enabled && !!appId,
})
}
export function useInvalidateSavedMessages(
appSourceType: AppSourceType,
appId: string,
) {
const queryClient = useQueryClient()
return () => {
queryClient.invalidateQueries({
queryKey: savedMessagesQueryKeys.all(appSourceType, appId),
})
}
}
export function useSaveMessageMutation(
appSourceType: AppSourceType,
appId: string,
) {
const { t } = useTranslation()
const invalidate = useInvalidateSavedMessages(appSourceType, appId)
return useMutation({
mutationFn: (messageId: string) =>
saveMessage(messageId, appSourceType, appId),
onSuccess: () => {
Toast.notify({ type: 'success', message: t('api.saved', { ns: 'common' }) })
invalidate()
},
})
}
export function useRemoveMessageMutation(
appSourceType: AppSourceType,
appId: string,
) {
const { t } = useTranslation()
const invalidate = useInvalidateSavedMessages(appSourceType, appId)
return useMutation({
mutationFn: (messageId: string) =>
removeMessage(messageId, appSourceType, appId),
onSuccess: () => {
Toast.notify({ type: 'success', message: t('api.remove', { ns: 'common' }) })
invalidate()
},
})
}

View File

@@ -1,65 +1,29 @@
'use client'
import type { FC } from 'react'
import type {
MoreLikeThisConfig,
PromptConfig,
SavedMessage,
TextToSpeechConfig,
} from '@/models/debug'
import type { InputValueTypes, Task } from './types'
import type { InstalledApp } from '@/models/explore'
import type { SiteInfo } from '@/models/share'
import type { VisionFile, VisionSettings } from '@/types/app'
import {
RiBookmark3Line,
RiErrorWarningFill,
} from '@remixicon/react'
import type { VisionFile } from '@/types/app'
import { useBoolean } from 'ahooks'
import { useSearchParams } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import SavedItems from '@/app/components/app/text-generate/saved-items'
import AppIcon from '@/app/components/base/app-icon'
import Badge from '@/app/components/base/badge'
import Loading from '@/app/components/base/loading'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import Toast from '@/app/components/base/toast'
import Res from '@/app/components/share/text-generation/result'
import RunOnce from '@/app/components/share/text-generation/run-once'
import { appDefaultIconBackground, BATCH_CONCURRENCY } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useWebAppStore } from '@/context/web-app-context'
import { useAppFavicon } from '@/hooks/use-app-favicon'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { changeLanguage } from '@/i18n-config/client'
import { AccessMode } from '@/models/access-control'
import { AppSourceType, fetchSavedMessage as doFetchSavedMessage, removeMessage, saveMessage } from '@/service/share'
import { Resolution, TransferMethod } from '@/types/app'
import { AppSourceType } from '@/service/share'
import { cn } from '@/utils/classnames'
import { userInputsFormToPromptVariables } from '@/utils/model-config'
import TabHeader from '../../base/tab-header'
import MenuDropdown from './menu-dropdown'
import HeaderSection from './components/header-section'
import PoweredBy from './components/powered-by'
import ResultPanel from './components/result-panel'
import { useAppConfig } from './hooks/use-app-config'
import { useBatchTasks } from './hooks/use-batch-tasks'
import { useRemoveMessageMutation, useSavedMessages, useSaveMessageMutation } from './hooks/use-saved-messages'
import RunBatch from './run-batch'
import ResDownload from './run-batch/res-download'
const GROUP_SIZE = BATCH_CONCURRENCY // to avoid RPM(Request per minute) limit. The group task finished then the next group.
enum TaskStatus {
pending = 'pending',
running = 'running',
completed = 'completed',
failed = 'failed',
}
type TaskParam = {
inputs: Record<string, any>
}
type Task = {
id: number
status: TaskStatus
params: TaskParam
}
import { TaskStatus } from './types'
export type IMainProps = {
isInstalledApp?: boolean
@@ -71,9 +35,6 @@ const TextGeneration: FC<IMainProps> = ({
isInstalledApp = false,
isWorkflow = false,
}) => {
const { notify } = Toast
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
const { t } = useTranslation()
const media = useBreakpoints()
const isPC = media === MediaType.pc
@@ -82,325 +43,81 @@ const TextGeneration: FC<IMainProps> = ({
const mode = searchParams.get('mode') || 'create'
const [currentTab, setCurrentTab] = useState<string>(['create', 'batch'].includes(mode) ? mode : 'create')
// Notice this situation isCallBatchAPI but not in batch tab
const [isCallBatchAPI, setIsCallBatchAPI] = useState(false)
const isInBatchTab = currentTab === 'batch'
const [inputs, doSetInputs] = useState<Record<string, any>>({})
// App configuration derived from store
const {
appId,
siteInfo,
customConfig,
promptConfig,
moreLikeThisConfig,
textToSpeechConfig,
visionConfig,
accessMode,
isReady,
} = useAppConfig()
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
// Saved messages (React Query)
const { data: savedMessages = [] } = useSavedMessages(appSourceType, appId, !isWorkflow)
const saveMutation = useSaveMessageMutation(appSourceType, appId)
const removeMutation = useRemoveMessageMutation(appSourceType, appId)
// Batch task management
const {
isCallBatchAPI,
controlRetry,
allTaskList,
showTaskList,
noPendingTask,
allSuccessTaskList,
allFailedTaskList,
allTasksRun,
exportRes,
clearBatchState,
startBatchRun,
handleCompleted,
handleRetryAllFailedTask,
} = useBatchTasks(promptConfig)
// Input state with ref for accessing latest value in async callbacks
const [inputs, doSetInputs] = useState<Record<string, InputValueTypes>>({})
const inputsRef = useRef(inputs)
const setInputs = useCallback((newInputs: Record<string, any>) => {
const setInputs = useCallback((newInputs: Record<string, InputValueTypes>) => {
doSetInputs(newInputs)
inputsRef.current = newInputs
}, [])
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const [appId, setAppId] = useState<string>('')
const [siteInfo, setSiteInfo] = useState<SiteInfo | null>(null)
const [customConfig, setCustomConfig] = useState<Record<string, any> | null>(null)
const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null)
const [moreLikeThisConfig, setMoreLikeThisConfig] = useState<MoreLikeThisConfig | null>(null)
const [textToSpeechConfig, setTextToSpeechConfig] = useState<TextToSpeechConfig | null>(null)
// save message
const [savedMessages, setSavedMessages] = useState<SavedMessage[]>([])
const fetchSavedMessage = useCallback(async () => {
if (!appId)
return
const res: any = await doFetchSavedMessage(appSourceType, appId)
setSavedMessages(res.data)
}, [appSourceType, appId])
const handleSaveMessage = async (messageId: string) => {
await saveMessage(messageId, appSourceType, appId)
notify({ type: 'success', message: t('api.saved', { ns: 'common' }) })
fetchSavedMessage()
}
const handleRemoveSavedMessage = async (messageId: string) => {
await removeMessage(messageId, appSourceType, appId)
notify({ type: 'success', message: t('api.remove', { ns: 'common' }) })
fetchSavedMessage()
}
// send message task
// Send control signals
const [controlSend, setControlSend] = useState(0)
const [controlStopResponding, setControlStopResponding] = useState(0)
const [visionConfig, setVisionConfig] = useState<VisionSettings>({
enabled: false,
number_limits: 2,
detail: Resolution.low,
transfer_methods: [TransferMethod.local_file],
})
const [completionFiles, setCompletionFiles] = useState<VisionFile[]>([])
const [runControl, setRunControl] = useState<{ onStop: () => Promise<void> | void, isStopping: boolean } | null>(null)
useEffect(() => {
if (isCallBatchAPI)
setRunControl(null)
}, [isCallBatchAPI])
// Result panel visibility
const [isShowResultPanel, { setTrue: doShowResultPanel, setFalse: hideResultPanel }] = useBoolean(false)
const showResultPanel = useCallback(() => {
// Delay to avoid useClickAway closing the panel immediately
setTimeout(doShowResultPanel, 0)
}, [doShowResultPanel])
const [resultExisted, setResultExisted] = useState(false)
const handleSend = () => {
setIsCallBatchAPI(false)
const handleSend = useCallback(() => {
clearBatchState()
setControlSend(Date.now())
// eslint-disable-next-line ts/no-use-before-define
setAllTaskList([]) // clear batch task running status
// eslint-disable-next-line ts/no-use-before-define
showResultPanel()
}
}, [clearBatchState, showResultPanel])
const [controlRetry, setControlRetry] = useState(0)
const handleRetryAllFailedTask = () => {
setControlRetry(Date.now())
}
const [allTaskList, doSetAllTaskList] = useState<Task[]>([])
const allTaskListRef = useRef<Task[]>([])
const getLatestTaskList = () => allTaskListRef.current
const setAllTaskList = (taskList: Task[]) => {
doSetAllTaskList(taskList)
allTaskListRef.current = taskList
}
const pendingTaskList = allTaskList.filter(task => task.status === TaskStatus.pending)
const noPendingTask = pendingTaskList.length === 0
const showTaskList = allTaskList.filter(task => task.status !== TaskStatus.pending)
const currGroupNumRef = useRef(0)
const setCurrGroupNum = (num: number) => {
currGroupNumRef.current = num
}
const getCurrGroupNum = () => {
return currGroupNumRef.current
}
const allSuccessTaskList = allTaskList.filter(task => task.status === TaskStatus.completed)
const allFailedTaskList = allTaskList.filter(task => task.status === TaskStatus.failed)
const allTasksFinished = allTaskList.every(task => task.status === TaskStatus.completed)
const allTasksRun = allTaskList.every(task => [TaskStatus.completed, TaskStatus.failed].includes(task.status))
const batchCompletionResRef = useRef<Record<string, string>>({})
const setBatchCompletionRes = (res: Record<string, string>) => {
batchCompletionResRef.current = res
}
const getBatchCompletionRes = () => batchCompletionResRef.current
const exportRes = allTaskList.map((task) => {
const batchCompletionResLatest = getBatchCompletionRes()
const res: Record<string, string> = {}
const { inputs } = task.params
promptConfig?.prompt_variables.forEach((v) => {
res[v.name] = inputs[v.key]
})
let result = batchCompletionResLatest[task.id]
// task might return multiple fields, should marshal object to string
if (typeof batchCompletionResLatest[task.id] === 'object')
result = JSON.stringify(result)
res[t('generation.completionResult', { ns: 'share' })] = result
return res
})
const checkBatchInputs = (data: string[][]) => {
if (!data || data.length === 0) {
notify({ type: 'error', message: t('generation.errorMsg.empty', { ns: 'share' }) })
return false
}
const headerData = data[0]
let isMapVarName = true
promptConfig?.prompt_variables.forEach((item, index) => {
if (!isMapVarName)
return
if (item.name !== headerData[index])
isMapVarName = false
})
if (!isMapVarName) {
notify({ type: 'error', message: t('generation.errorMsg.fileStructNotMatch', { ns: 'share' }) })
return false
}
let payloadData = data.slice(1)
if (payloadData.length === 0) {
notify({ type: 'error', message: t('generation.errorMsg.atLeastOne', { ns: 'share' }) })
return false
}
// check middle empty line
const allEmptyLineIndexes = payloadData.filter(item => item.every(i => i === '')).map(item => payloadData.indexOf(item))
if (allEmptyLineIndexes.length > 0) {
let hasMiddleEmptyLine = false
let startIndex = allEmptyLineIndexes[0] - 1
allEmptyLineIndexes.forEach((index) => {
if (hasMiddleEmptyLine)
return
if (startIndex + 1 !== index) {
hasMiddleEmptyLine = true
return
}
startIndex++
})
if (hasMiddleEmptyLine) {
notify({ type: 'error', message: t('generation.errorMsg.emptyLine', { ns: 'share', rowIndex: startIndex + 2 }) })
return false
}
}
// check row format
payloadData = payloadData.filter(item => !item.every(i => i === ''))
// after remove empty rows in the end, checked again
if (payloadData.length === 0) {
notify({ type: 'error', message: t('generation.errorMsg.atLeastOne', { ns: 'share' }) })
return false
}
let errorRowIndex = 0
let requiredVarName = ''
let moreThanMaxLengthVarName = ''
let maxLength = 0
payloadData.forEach((item, index) => {
if (errorRowIndex !== 0)
return
promptConfig?.prompt_variables.forEach((varItem, varIndex) => {
if (errorRowIndex !== 0)
return
if (varItem.type === 'string' && varItem.max_length) {
if (item[varIndex].length > varItem.max_length) {
moreThanMaxLengthVarName = varItem.name
maxLength = varItem.max_length
errorRowIndex = index + 1
return
}
}
if (!varItem.required)
return
if (item[varIndex].trim() === '') {
requiredVarName = varItem.name
errorRowIndex = index + 1
}
})
})
if (errorRowIndex !== 0) {
if (requiredVarName)
notify({ type: 'error', message: t('generation.errorMsg.invalidLine', { ns: 'share', rowIndex: errorRowIndex + 1, varName: requiredVarName }) })
if (moreThanMaxLengthVarName)
notify({ type: 'error', message: t('generation.errorMsg.moreThanMaxLengthLine', { ns: 'share', rowIndex: errorRowIndex + 1, varName: moreThanMaxLengthVarName, maxLength }) })
return false
}
return true
}
const handleRunBatch = (data: string[][]) => {
if (!checkBatchInputs(data))
const handleRunBatch = useCallback((data: string[][]) => {
if (!startBatchRun(data))
return
if (!allTasksFinished) {
notify({ type: 'info', message: t('errorMessage.waitForBatchResponse', { ns: 'appDebug' }) })
return
}
const payloadData = data.filter(item => !item.every(i => i === '')).slice(1)
const varLen = promptConfig?.prompt_variables.length || 0
setIsCallBatchAPI(true)
const allTaskList: Task[] = payloadData.map((item, i) => {
const inputs: Record<string, any> = {}
if (varLen > 0) {
item.slice(0, varLen).forEach((input, index) => {
const varSchema = promptConfig?.prompt_variables[index]
inputs[varSchema?.key as string] = input
if (!input) {
if (varSchema?.type === 'string' || varSchema?.type === 'paragraph')
inputs[varSchema?.key as string] = ''
else
inputs[varSchema?.key as string] = undefined
}
})
}
return {
id: i + 1,
status: i < GROUP_SIZE ? TaskStatus.running : TaskStatus.pending,
params: {
inputs,
},
}
})
setAllTaskList(allTaskList)
setCurrGroupNum(0)
setRunControl(null)
setControlSend(Date.now())
// clear run once task status
setControlStopResponding(Date.now())
// eslint-disable-next-line ts/no-use-before-define
showResultPanel()
}
const handleCompleted = (completionRes: string, taskId?: number, isSuccess?: boolean) => {
const allTaskListLatest = getLatestTaskList()
const batchCompletionResLatest = getBatchCompletionRes()
const pendingTaskList = allTaskListLatest.filter(task => task.status === TaskStatus.pending)
const runTasksCount = 1 + allTaskListLatest.filter(task => [TaskStatus.completed, TaskStatus.failed].includes(task.status)).length
const needToAddNextGroupTask = (getCurrGroupNum() !== runTasksCount) && pendingTaskList.length > 0 && (runTasksCount % GROUP_SIZE === 0 || (allTaskListLatest.length - runTasksCount < GROUP_SIZE))
// avoid add many task at the same time
if (needToAddNextGroupTask)
setCurrGroupNum(runTasksCount)
}, [startBatchRun, showResultPanel])
const nextPendingTaskIds = needToAddNextGroupTask ? pendingTaskList.slice(0, GROUP_SIZE).map(item => item.id) : []
const newAllTaskList = allTaskListLatest.map((item) => {
if (item.id === taskId) {
return {
...item,
status: isSuccess ? TaskStatus.completed : TaskStatus.failed,
}
}
if (needToAddNextGroupTask && nextPendingTaskIds.includes(item.id)) {
return {
...item,
status: TaskStatus.running,
}
}
return item
})
setAllTaskList(newAllTaskList)
if (taskId) {
setBatchCompletionRes({
...batchCompletionResLatest,
[`${taskId}`]: completionRes,
})
}
}
const appData = useWebAppStore(s => s.appInfo)
const appParams = useWebAppStore(s => s.appParams)
const accessMode = useWebAppStore(s => s.webAppAccessMode)
useEffect(() => {
(async () => {
if (!appData || !appParams)
return
if (!isWorkflow)
fetchSavedMessage()
const { app_id: appId, site: siteInfo, custom_config } = appData
setAppId(appId)
setSiteInfo(siteInfo as SiteInfo)
setCustomConfig(custom_config)
await changeLanguage(siteInfo.default_language)
const { user_input_form, more_like_this, file_upload, text_to_speech }: any = appParams
setVisionConfig({
// legacy of image upload compatible
...file_upload,
transfer_methods: file_upload?.allowed_file_upload_methods || file_upload?.allowed_upload_methods,
// legacy of image upload compatible
image_file_size_limit: appParams?.system_parameters.image_file_size_limit,
fileUploadConfig: appParams?.system_parameters,
} as any)
const prompt_variables = userInputsFormToPromptVariables(user_input_form)
setPromptConfig({
prompt_template: '', // placeholder for future
prompt_variables,
} as PromptConfig)
setMoreLikeThisConfig(more_like_this)
setTextToSpeechConfig(text_to_speech)
})()
}, [appData, appParams, fetchSavedMessage, isWorkflow])
// Can Use metadata(https://beta.nextjs.org/docs/api-reference/metadata) to set title. But it only works in server side client.
useDocumentTitle(siteInfo?.title || t('generation.title', { ns: 'share' }))
useAppFavicon({
enable: !isInstalledApp,
icon_type: siteInfo?.icon_type,
@@ -409,15 +126,6 @@ const TextGeneration: FC<IMainProps> = ({
icon_url: siteInfo?.icon_url,
})
const [isShowResultPanel, { setTrue: doShowResultPanel, setFalse: hideResultPanel }] = useBoolean(false)
const showResultPanel = () => {
// fix: useClickAway hideResSidebar will close sidebar
setTimeout(() => {
doShowResultPanel()
}, 0)
}
const [resultExisted, setResultExisted] = useState(false)
const renderRes = (task?: Task) => (
<Res
key={task?.id}
@@ -425,7 +133,7 @@ const TextGeneration: FC<IMainProps> = ({
isCallBatchAPI={isCallBatchAPI}
isPC={isPC}
isMobile={!isPC}
appSourceType={isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp}
appSourceType={appSourceType}
appId={appId}
isError={task?.status === TaskStatus.failed}
promptConfig={promptConfig}
@@ -435,7 +143,7 @@ const TextGeneration: FC<IMainProps> = ({
controlRetry={task?.status === TaskStatus.failed ? controlRetry : 0}
controlStopResponding={controlStopResponding}
onShowRes={showResultPanel}
handleSaveMessage={handleSaveMessage}
handleSaveMessage={id => saveMutation.mutate(id)}
taskId={task?.id}
onCompleted={handleCompleted}
visionConfig={visionConfig}
@@ -448,69 +156,14 @@ const TextGeneration: FC<IMainProps> = ({
/>
)
const renderBatchRes = () => {
return (showTaskList.map(task => renderRes(task)))
}
const renderResWrap = (
<div
className={cn(
'relative flex h-full flex-col',
!isPC && 'h-[calc(100vh_-_36px)] rounded-t-2xl shadow-lg backdrop-blur-sm',
!isPC
? isShowResultPanel
? 'bg-background-default-burn'
: 'border-t-[0.5px] border-divider-regular bg-components-panel-bg'
: 'bg-chatbot-bg',
)}
>
{isCallBatchAPI && (
<div className={cn(
'flex shrink-0 items-center justify-between px-14 pb-2 pt-9',
!isPC && 'px-4 pb-1 pt-3',
)}
>
<div className="system-md-semibold-uppercase text-text-primary">{t('generation.executions', { ns: 'share', num: allTaskList.length })}</div>
{allSuccessTaskList.length > 0 && (
<ResDownload
isMobile={!isPC}
values={exportRes}
/>
)}
</div>
)}
<div className={cn(
'flex h-0 grow flex-col overflow-y-auto',
isPC && 'px-14 py-8',
isPC && isCallBatchAPI && 'pt-0',
!isPC && 'p-0 pb-2',
)}
>
{!isCallBatchAPI ? renderRes() : renderBatchRes()}
{!noPendingTask && (
<div className="mt-4">
<Loading type="area" />
</div>
)}
</div>
{isCallBatchAPI && allFailedTaskList.length > 0 && (
<div className="absolute bottom-6 left-1/2 z-10 flex -translate-x-1/2 items-center gap-2 rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg backdrop-blur-sm">
<RiErrorWarningFill className="h-4 w-4 text-text-destructive" />
<div className="system-sm-medium text-text-secondary">{t('generation.batchFailed.info', { ns: 'share', num: allFailedTaskList.length })}</div>
<div className="h-3.5 w-px bg-divider-regular"></div>
<div onClick={handleRetryAllFailedTask} className="system-sm-semibold-uppercase cursor-pointer text-text-accent">{t('generation.batchFailed.retry', { ns: 'share' })}</div>
</div>
)}
</div>
)
if (!appId || !siteInfo || !promptConfig) {
if (!isReady) {
return (
<div className="flex h-screen items-center">
<Loading type="app" />
</div>
)
}
return (
<div className={cn(
'bg-background-default-burn',
@@ -519,54 +172,24 @@ const TextGeneration: FC<IMainProps> = ({
isInstalledApp ? 'h-full rounded-2xl shadow-md' : 'h-screen',
)}
>
{/* Left */}
{/* Left panel */}
<div className={cn(
'relative flex h-full shrink-0 flex-col',
isPC ? 'w-[600px] max-w-[50%]' : resultExisted ? 'h-[calc(100%_-_64px)]' : '',
isInstalledApp && 'rounded-l-2xl',
)}
>
{/* header */}
<div className={cn('shrink-0 space-y-4 border-b border-divider-subtle', isPC ? 'bg-components-panel-bg p-8 pb-0' : 'p-4 pb-0')}>
<div className="flex items-center gap-3">
<AppIcon
size={isPC ? 'large' : 'small'}
iconType={siteInfo.icon_type}
icon={siteInfo.icon}
background={siteInfo.icon_background || appDefaultIconBackground}
imageUrl={siteInfo.icon_url}
/>
<div className="system-md-semibold grow truncate text-text-secondary">{siteInfo.title}</div>
<MenuDropdown hideLogout={isInstalledApp || accessMode === AccessMode.PUBLIC} data={siteInfo} />
</div>
{siteInfo.description && (
<div className="system-xs-regular text-text-tertiary">{siteInfo.description}</div>
)}
<TabHeader
items={[
{ id: 'create', name: t('generation.tabs.create', { ns: 'share' }) },
{ id: 'batch', name: t('generation.tabs.batch', { ns: 'share' }) },
...(!isWorkflow
? [{
id: 'saved',
name: t('generation.tabs.saved', { ns: 'share' }),
isRight: true,
icon: <RiBookmark3Line className="h-4 w-4" />,
extra: savedMessages.length > 0
? (
<Badge className="ml-1">
{savedMessages.length}
</Badge>
)
: null,
}]
: []),
]}
value={currentTab}
onChange={setCurrentTab}
/>
</div>
{/* form */}
<HeaderSection
isPC={isPC}
isInstalledApp={isInstalledApp}
isWorkflow={isWorkflow}
siteInfo={siteInfo!}
accessMode={accessMode}
savedMessages={savedMessages}
currentTab={currentTab}
onTabChange={setCurrentTab}
/>
{/* Form content */}
<div className={cn(
'h-0 grow overflow-y-auto bg-components-panel-bg',
isPC ? 'px-8' : 'px-4',
@@ -575,20 +198,20 @@ const TextGeneration: FC<IMainProps> = ({
>
<div className={cn(currentTab === 'create' ? 'block' : 'hidden')}>
<RunOnce
siteInfo={siteInfo}
siteInfo={siteInfo!}
inputs={inputs}
inputsRef={inputsRef}
onInputsChange={setInputs}
promptConfig={promptConfig}
promptConfig={promptConfig!}
onSend={handleSend}
visionConfig={visionConfig}
onVisionFilesChange={setCompletionFiles}
runControl={runControl}
/>
</div>
<div className={cn(isInBatchTab ? 'block' : 'hidden')}>
<div className={cn(currentTab === 'batch' ? 'block' : 'hidden')}>
<RunBatch
vars={promptConfig.prompt_variables}
vars={promptConfig!.prompt_variables}
onSend={handleRunBatch}
isAllFinished={allTasksRun}
/>
@@ -598,31 +221,15 @@ const TextGeneration: FC<IMainProps> = ({
className={cn(isPC ? 'mt-6' : 'mt-4')}
isShowTextToSpeech={textToSpeechConfig?.enabled}
list={savedMessages}
onRemove={handleRemoveSavedMessage}
onRemove={id => removeMutation.mutate(id)}
onStartCreateContent={() => setCurrentTab('create')}
/>
)}
</div>
{/* powered by */}
{!customConfig?.remove_webapp_brand && (
<div className={cn(
'flex shrink-0 items-center gap-1.5 bg-components-panel-bg py-3',
isPC ? 'px-8' : 'px-4',
!isPC && resultExisted && 'rounded-b-2xl border-b-[0.5px] border-divider-regular',
)}
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
{
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" />
: customConfig?.replace_webapp_logo
? <img src={`${customConfig?.replace_webapp_logo}`} alt="logo" className="block h-5 w-auto" />
: <DifyLogo size="small" />
}
</div>
)}
<PoweredBy isPC={isPC} resultExisted={resultExisted} customConfig={customConfig} />
</div>
{/* Result */}
{/* Right panel - Results */}
<div className={cn(
isPC
? 'h-full w-0 grow'
@@ -640,17 +247,26 @@ const TextGeneration: FC<IMainProps> = ({
? 'flex items-center justify-center p-2 pt-6'
: 'absolute left-0 top-0 z-10 flex w-full items-center justify-center px-2 pb-[57px] pt-[3px]',
)}
onClick={() => {
if (isShowResultPanel)
hideResultPanel()
else
showResultPanel()
}}
onClick={() => isShowResultPanel ? hideResultPanel() : showResultPanel()}
>
<div className="h-1 w-8 cursor-grab rounded bg-divider-solid" />
</div>
)}
{renderResWrap}
<ResultPanel
isPC={isPC}
isShowResultPanel={isShowResultPanel}
isCallBatchAPI={isCallBatchAPI}
totalTasks={allTaskList.length}
successCount={allSuccessTaskList.length}
failedCount={allFailedTaskList.length}
noPendingTask={noPendingTask}
exportRes={exportRes}
onRetryFailed={handleRetryAllFailedTask}
>
{!isCallBatchAPI
? renderRes()
: showTaskList.map(task => renderRes(task))}
</ResultPanel>
</div>
</div>
)

View File

@@ -24,7 +24,7 @@ const Header: FC<IResultHeaderProps> = ({
}) => {
const { t } = useTranslation()
return (
<div className="flex w-full items-center justify-between ">
<div className="flex w-full items-center justify-between">
<div className="text-2xl font-normal leading-4 text-gray-800">{t('generation.resultTitle', { ns: 'share' })}</div>
<div className="flex items-center space-x-2">
<Button
@@ -50,7 +50,7 @@ const Header: FC<IResultHeaderProps> = ({
rating: null,
})
}}
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border border-primary-200 bg-primary-100 !text-primary-600 hover:border-primary-300 hover:bg-primary-200"
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border border-primary-200 bg-primary-100 !text-primary-600 hover:border-primary-300 hover:bg-primary-200"
>
<HandThumbUpIcon width={16} height={16} />
</div>
@@ -67,7 +67,7 @@ const Header: FC<IResultHeaderProps> = ({
rating: null,
})
}}
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border border-red-200 bg-red-100 !text-red-600 hover:border-red-300 hover:bg-red-200"
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border border-red-200 bg-red-100 !text-red-600 hover:border-red-300 hover:bg-red-200"
>
<HandThumbDownIcon width={16} height={16} />
</div>

View File

@@ -0,0 +1,904 @@
import type { UseTextGenerationProps } from './use-text-generation'
import { act, renderHook } from '@testing-library/react'
import Toast from '@/app/components/base/toast'
import {
AppSourceType,
sendCompletionMessage,
sendWorkflowMessage,
stopChatMessageResponding,
stopWorkflowMessage,
} from '@/service/share'
import { TransferMethod } from '@/types/app'
import { sleep } from '@/utils'
import { useTextGeneration } from './use-text-generation'
// Mock external services
vi.mock('@/service/share', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>()
return {
...actual,
sendCompletionMessage: vi.fn(),
sendWorkflowMessage: vi.fn(() => Promise.resolve()),
stopChatMessageResponding: vi.fn(() => Promise.resolve()),
stopWorkflowMessage: vi.fn(() => Promise.resolve()),
updateFeedback: vi.fn(() => Promise.resolve()),
}
})
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
}))
vi.mock('@/utils', () => ({
sleep: vi.fn(() => Promise.resolve()),
}))
vi.mock('@/app/components/base/file-uploader/utils', () => ({
getProcessedFiles: vi.fn((files: unknown[]) => files),
getFilesInLogs: vi.fn(() => []),
}))
vi.mock('@/utils/model-config', () => ({
formatBooleanInputs: vi.fn((_vars: unknown, inputs: unknown) => inputs),
}))
// Extracted parameter types for typed mock implementations
type CompletionBody = Parameters<typeof sendCompletionMessage>[0]
type CompletionCbs = Parameters<typeof sendCompletionMessage>[1]
type WorkflowBody = Parameters<typeof sendWorkflowMessage>[0]
type WorkflowCbs = Parameters<typeof sendWorkflowMessage>[1]
// Factory for default hook props
function createProps(overrides: Partial<UseTextGenerationProps> = {}): UseTextGenerationProps {
return {
isWorkflow: false,
isCallBatchAPI: false,
isPC: true,
appSourceType: AppSourceType.webApp,
appId: 'app-1',
promptConfig: { prompt_template: '', prompt_variables: [] },
inputs: {},
onShowRes: vi.fn(),
onCompleted: vi.fn(),
visionConfig: { enabled: false } as UseTextGenerationProps['visionConfig'],
completionFiles: [],
onRunStart: vi.fn(),
...overrides,
}
}
describe('useTextGeneration', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Initial state
describe('initial state', () => {
it('should return correct default values', () => {
const { result } = renderHook(() => useTextGeneration(createProps()))
expect(result.current.isResponding).toBe(false)
expect(result.current.completionRes).toBe('')
expect(result.current.workflowProcessData).toBeUndefined()
expect(result.current.messageId).toBeNull()
expect(result.current.feedback).toEqual({ rating: null })
expect(result.current.isStopping).toBe(false)
expect(result.current.currentTaskId).toBeNull()
expect(result.current.controlClearMoreLikeThis).toBe(0)
})
it('should expose handler functions', () => {
const { result } = renderHook(() => useTextGeneration(createProps()))
expect(typeof result.current.handleSend).toBe('function')
expect(typeof result.current.handleStop).toBe('function')
expect(typeof result.current.handleFeedback).toBe('function')
})
})
// Feedback
describe('handleFeedback', () => {
it('should call updateFeedback API and update state', async () => {
const { updateFeedback } = await import('@/service/share')
const { result } = renderHook(() => useTextGeneration(createProps()))
await act(async () => {
await result.current.handleFeedback({ rating: 'like' })
})
expect(updateFeedback).toHaveBeenCalledWith(
expect.objectContaining({ body: { rating: 'like', content: undefined } }),
AppSourceType.webApp,
'app-1',
)
expect(result.current.feedback).toEqual({ rating: 'like' })
})
})
// Stop
describe('handleStop', () => {
it('should do nothing when no currentTaskId', async () => {
const { stopChatMessageResponding } = await import('@/service/share')
const { result } = renderHook(() => useTextGeneration(createProps()))
await act(async () => {
await result.current.handleStop()
})
expect(stopChatMessageResponding).not.toHaveBeenCalled()
})
it('should call stopWorkflowMessage for workflow mode', async () => {
const { stopWorkflowMessage, sendWorkflowMessage } = await import('@/service/share')
const props = createProps({ isWorkflow: true })
const { result } = renderHook(() => useTextGeneration(props))
// Trigger a send to set currentTaskId (mock will set it via callbacks)
// Instead, we test that handleStop guards against empty taskId
await act(async () => {
await result.current.handleStop()
})
// No task to stop
expect(stopWorkflowMessage).not.toHaveBeenCalled()
expect(sendWorkflowMessage).toBeDefined()
})
})
// Send - validation
describe('handleSend - validation', () => {
it('should show toast when called while responding', async () => {
const { sendCompletionMessage } = await import('@/service/share')
const { result } = renderHook(() => useTextGeneration(createProps({ controlSend: 1 })))
// First send sets isResponding true
// Second send should show warning
await act(async () => {
await result.current.handleSend()
})
expect(sendCompletionMessage).toHaveBeenCalled()
})
it('should validate required prompt variables', async () => {
const Toast = (await import('@/app/components/base/toast')).default
const props = createProps({
promptConfig: {
prompt_template: '',
prompt_variables: [
{ key: 'name', name: 'Name', type: 'string', required: true },
] as UseTextGenerationProps['promptConfig'] extends infer T ? T extends { prompt_variables: infer V } ? V : never : never,
},
inputs: {}, // missing required 'name'
})
const { result } = renderHook(() => useTextGeneration(props))
await act(async () => {
await result.current.handleSend()
})
expect(Toast.notify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
it('should pass validation for batch API mode', async () => {
const { sendCompletionMessage } = await import('@/service/share')
const props = createProps({ isCallBatchAPI: true })
const { result } = renderHook(() => useTextGeneration(props))
await act(async () => {
await result.current.handleSend()
})
// Batch mode skips validation - should call send
expect(sendCompletionMessage).toHaveBeenCalled()
})
})
// Send - API calls
describe('handleSend - API', () => {
it('should call sendCompletionMessage for non-workflow mode', async () => {
const { sendCompletionMessage } = await import('@/service/share')
const props = createProps({ isWorkflow: false })
const { result } = renderHook(() => useTextGeneration(props))
await act(async () => {
await result.current.handleSend()
})
expect(sendCompletionMessage).toHaveBeenCalledWith(
expect.objectContaining({ inputs: {} }),
expect.objectContaining({
onData: expect.any(Function),
onCompleted: expect.any(Function),
onError: expect.any(Function),
}),
AppSourceType.webApp,
'app-1',
)
})
it('should call sendWorkflowMessage for workflow mode', async () => {
const { sendWorkflowMessage } = await import('@/service/share')
const props = createProps({ isWorkflow: true })
const { result } = renderHook(() => useTextGeneration(props))
await act(async () => {
await result.current.handleSend()
})
expect(sendWorkflowMessage).toHaveBeenCalledWith(
expect.objectContaining({ inputs: {} }),
expect.objectContaining({
onWorkflowStarted: expect.any(Function),
onNodeStarted: expect.any(Function),
onWorkflowFinished: expect.any(Function),
}),
AppSourceType.webApp,
'app-1',
)
})
it('should call onShowRes and onRunStart on mobile', async () => {
const onShowRes = vi.fn()
const onRunStart = vi.fn()
const props = createProps({ isPC: false, onShowRes, onRunStart })
const { result } = renderHook(() => useTextGeneration(props))
await act(async () => {
await result.current.handleSend()
})
expect(onShowRes).toHaveBeenCalled()
expect(onRunStart).toHaveBeenCalled()
})
})
// Effects
describe('effects', () => {
it('should trigger send when controlSend changes', async () => {
const { sendCompletionMessage } = await import('@/service/share')
const { result, rerender } = renderHook(
(props: UseTextGenerationProps) => useTextGeneration(props),
{ initialProps: createProps({ controlSend: 0 }) },
)
// Change controlSend to trigger the effect
await act(async () => {
rerender(createProps({ controlSend: Date.now() }))
})
expect(sendCompletionMessage).toHaveBeenCalled()
expect(result.current.controlClearMoreLikeThis).toBeGreaterThan(0)
})
it('should trigger send when controlRetry changes', async () => {
const { sendCompletionMessage } = await import('@/service/share')
await act(async () => {
renderHook(() => useTextGeneration(createProps({ controlRetry: Date.now() })))
})
expect(sendCompletionMessage).toHaveBeenCalled()
})
it('should sync run control with parent via onRunControlChange', () => {
const onRunControlChange = vi.fn()
renderHook(() => useTextGeneration(createProps({ onRunControlChange })))
// Initially not responding, so should pass null
expect(onRunControlChange).toHaveBeenCalledWith(null)
})
})
// handleStop with active task
describe('handleStop - with active task', () => {
it('should call stopWorkflowMessage for workflow', async () => {
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
vi.mocked(sendWorkflowMessage).mockImplementationOnce(
(async (_data: WorkflowBody, callbacks: WorkflowCbs) => {
callbacks.onWorkflowStarted({ workflow_run_id: 'run-1', task_id: 'task-1' } as never)
}) as unknown as typeof sendWorkflowMessage,
)
const { result } = renderHook(() => useTextGeneration(createProps({ isWorkflow: true })))
await act(async () => {
await result.current.handleSend()
})
await act(async () => {
await result.current.handleStop()
})
expect(stopWorkflowMessage).toHaveBeenCalledWith('app-1', 'task-1', AppSourceType.webApp, 'app-1')
})
it('should call stopChatMessageResponding for completion', async () => {
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
vi.mocked(sendCompletionMessage).mockImplementationOnce(
(async (_data: CompletionBody, { onData, getAbortController }: CompletionCbs) => {
getAbortController?.(new AbortController())
onData('chunk', true, { messageId: 'msg-1', taskId: 'task-1' })
}) as unknown as typeof sendCompletionMessage,
)
const { result } = renderHook(() => useTextGeneration(createProps()))
await act(async () => {
await result.current.handleSend()
})
await act(async () => {
await result.current.handleStop()
})
expect(stopChatMessageResponding).toHaveBeenCalledWith('app-1', 'task-1', AppSourceType.webApp, 'app-1')
})
it('should handle stop API errors gracefully', async () => {
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
vi.mocked(sendWorkflowMessage).mockImplementationOnce(
(async (_data: WorkflowBody, callbacks: WorkflowCbs) => {
callbacks.onWorkflowStarted({ workflow_run_id: 'run-1', task_id: 'task-1' } as never)
}) as unknown as typeof sendWorkflowMessage,
)
vi.mocked(stopWorkflowMessage).mockRejectedValueOnce(new Error('Network error'))
const { result } = renderHook(() => useTextGeneration(createProps({ isWorkflow: true })))
await act(async () => {
await result.current.handleSend()
})
await act(async () => {
await result.current.handleStop()
})
expect(Toast.notify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error', message: 'Network error' }),
)
expect(result.current.isStopping).toBe(false)
})
})
// File processing in handleSend
describe('handleSend - file processing', () => {
it('should process file-type and file-list prompt variables', async () => {
const fileValue = { name: 'doc.pdf', size: 100 }
const fileListValue = [{ name: 'a.pdf' }, { name: 'b.pdf' }]
const props = createProps({
promptConfig: {
prompt_template: '',
prompt_variables: [
{ key: 'doc', name: 'Document', type: 'file', required: false },
{ key: 'docs', name: 'Documents', type: 'file-list', required: false },
] as UseTextGenerationProps['promptConfig'] extends infer T ? T extends { prompt_variables: infer V } ? V : never : never,
},
inputs: { doc: fileValue, docs: fileListValue } as unknown as Record<string, UseTextGenerationProps['inputs'][string]>,
})
const { result } = renderHook(() => useTextGeneration(props))
await act(async () => {
await result.current.handleSend()
})
expect(sendCompletionMessage).toHaveBeenCalledWith(
expect.objectContaining({ inputs: expect.objectContaining({ doc: expect.anything(), docs: expect.anything() }) }),
expect.anything(),
expect.anything(),
expect.anything(),
)
})
it('should include vision files when vision is enabled', async () => {
const props = createProps({
visionConfig: { enabled: true, number_limits: 2, detail: 'low', transfer_methods: [] } as UseTextGenerationProps['visionConfig'],
completionFiles: [
{ transfer_method: TransferMethod.local_file, url: 'http://local', upload_file_id: 'f1' },
{ transfer_method: TransferMethod.remote_url, url: 'http://remote' },
] as UseTextGenerationProps['completionFiles'],
})
const { result } = renderHook(() => useTextGeneration(props))
await act(async () => {
await result.current.handleSend()
})
expect(sendCompletionMessage).toHaveBeenCalledWith(
expect.objectContaining({
files: expect.arrayContaining([
expect.objectContaining({ transfer_method: TransferMethod.local_file, url: '' }),
expect.objectContaining({ transfer_method: TransferMethod.remote_url, url: 'http://remote' }),
]),
}),
expect.anything(),
expect.anything(),
expect.anything(),
)
})
})
// Validation edge cases
describe('handleSend - validation edge cases', () => {
it('should block when files are uploading and no prompt variables', async () => {
const props = createProps({
promptConfig: { prompt_template: '', prompt_variables: [] },
completionFiles: [
{ transfer_method: TransferMethod.local_file, url: '' },
] as UseTextGenerationProps['completionFiles'],
})
const { result } = renderHook(() => useTextGeneration(props))
await act(async () => {
await result.current.handleSend()
})
expect(Toast.notify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'info' }),
)
expect(sendCompletionMessage).not.toHaveBeenCalled()
})
it('should skip boolean/checkbox vars in required check', async () => {
const props = createProps({
promptConfig: {
prompt_template: '',
prompt_variables: [
{ key: 'flag', name: 'Flag', type: 'boolean', required: true },
{ key: 'check', name: 'Check', type: 'checkbox', required: true },
] as UseTextGenerationProps['promptConfig'] extends infer T ? T extends { prompt_variables: infer V } ? V : never : never,
},
inputs: {},
})
const { result } = renderHook(() => useTextGeneration(props))
await act(async () => {
await result.current.handleSend()
})
// Should pass validation - boolean/checkbox are skipped
expect(sendCompletionMessage).toHaveBeenCalled()
})
it('should stop checking after first empty required var', async () => {
const props = createProps({
promptConfig: {
prompt_template: '',
prompt_variables: [
{ key: 'first', name: 'First', type: 'string', required: true },
{ key: 'second', name: 'Second', type: 'string', required: true },
] as UseTextGenerationProps['promptConfig'] extends infer T ? T extends { prompt_variables: infer V } ? V : never : never,
},
inputs: { second: 'value' },
})
const { result } = renderHook(() => useTextGeneration(props))
await act(async () => {
await result.current.handleSend()
})
// Error should mention 'First', not 'Second'
expect(Toast.notify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
it('should block when files uploading after vars pass', async () => {
const props = createProps({
promptConfig: {
prompt_template: '',
prompt_variables: [
{ key: 'name', name: 'Name', type: 'string', required: true },
] as UseTextGenerationProps['promptConfig'] extends infer T ? T extends { prompt_variables: infer V } ? V : never : never,
},
inputs: { name: 'Alice' },
completionFiles: [
{ transfer_method: TransferMethod.local_file, url: '' },
] as UseTextGenerationProps['completionFiles'],
})
const { result } = renderHook(() => useTextGeneration(props))
await act(async () => {
await result.current.handleSend()
})
expect(Toast.notify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'info' }),
)
expect(sendCompletionMessage).not.toHaveBeenCalled()
})
})
// sendCompletionMessage callbacks
describe('sendCompletionMessage callbacks', () => {
it('should accumulate text and track task/message via onData', async () => {
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
vi.mocked(sendCompletionMessage).mockImplementationOnce(
(async (_data: CompletionBody, { onData }: CompletionCbs) => {
onData('Hello ', true, { messageId: 'msg-1', taskId: 'task-1' })
onData('World', false, { messageId: 'msg-1', taskId: 'task-1' })
}) as unknown as typeof sendCompletionMessage,
)
const { result } = renderHook(() => useTextGeneration(createProps()))
await act(async () => {
await result.current.handleSend()
})
expect(result.current.completionRes).toBe('Hello World')
expect(result.current.currentTaskId).toBe('task-1')
})
it('should finalize state via onCompleted', async () => {
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
const onCompleted = vi.fn()
vi.mocked(sendCompletionMessage).mockImplementationOnce(
(async (_data: CompletionBody, callbacks: CompletionCbs) => {
callbacks.onData('result', true, { messageId: 'msg-1', taskId: 'task-1' })
callbacks.onCompleted()
}) as unknown as typeof sendCompletionMessage,
)
const { result } = renderHook(() => useTextGeneration(createProps({ onCompleted })))
await act(async () => {
await result.current.handleSend()
})
expect(result.current.isResponding).toBe(false)
expect(result.current.messageId).toBe('msg-1')
expect(onCompleted).toHaveBeenCalledWith('result', undefined, true)
})
it('should replace text via onMessageReplace', async () => {
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
vi.mocked(sendCompletionMessage).mockImplementationOnce(
(async (_data: CompletionBody, { onData, onMessageReplace }: CompletionCbs) => {
onData('old text', true, { messageId: 'msg-1', taskId: 'task-1' })
onMessageReplace!({ answer: 'replaced text' } as never)
}) as unknown as typeof sendCompletionMessage,
)
const { result } = renderHook(() => useTextGeneration(createProps()))
await act(async () => {
await result.current.handleSend()
})
expect(result.current.completionRes).toBe('replaced text')
})
it('should handle error via onError', async () => {
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
const onCompleted = vi.fn()
vi.mocked(sendCompletionMessage).mockImplementationOnce(
(async (_data: CompletionBody, { onError }: CompletionCbs) => {
onError('test error')
}) as unknown as typeof sendCompletionMessage,
)
const { result } = renderHook(() => useTextGeneration(createProps({ onCompleted })))
await act(async () => {
await result.current.handleSend()
})
expect(result.current.isResponding).toBe(false)
expect(onCompleted).toHaveBeenCalledWith('', undefined, false)
})
it('should store abort controller via getAbortController', async () => {
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
const abortController = new AbortController()
vi.mocked(sendCompletionMessage).mockImplementationOnce(
(async (_data: CompletionBody, { getAbortController }: CompletionCbs) => {
getAbortController?.(abortController)
}) as unknown as typeof sendCompletionMessage,
)
const { result } = renderHook(() => useTextGeneration(createProps()))
await act(async () => {
await result.current.handleSend()
})
// Verify abort controller is stored by triggering stop
expect(result.current.isResponding).toBe(true)
})
it('should show timeout warning when onCompleted fires after timeout', async () => {
// Default sleep mock resolves immediately, so timeout fires
let capturedCallbacks: CompletionCbs | null = null
vi.mocked(sendCompletionMessage).mockImplementationOnce(
(async (_data: CompletionBody, callbacks: CompletionCbs) => {
capturedCallbacks = callbacks
}) as unknown as typeof sendCompletionMessage,
)
const { result } = renderHook(() => useTextGeneration(createProps()))
await act(async () => {
await result.current.handleSend()
})
// Timeout has fired (sleep resolved immediately, isEndRef still false)
await act(async () => {
capturedCallbacks!.onCompleted()
})
expect(Toast.notify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'warning' }),
)
})
it('should show timeout warning when onError fires after timeout', async () => {
let capturedCallbacks: CompletionCbs | null = null
vi.mocked(sendCompletionMessage).mockImplementationOnce(
(async (_data: CompletionBody, callbacks: CompletionCbs) => {
capturedCallbacks = callbacks
}) as unknown as typeof sendCompletionMessage,
)
const { result } = renderHook(() => useTextGeneration(createProps()))
await act(async () => {
await result.current.handleSend()
})
await act(async () => {
capturedCallbacks!.onError('test error')
})
expect(Toast.notify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'warning' }),
)
})
})
// sendWorkflowMessage error handling
describe('sendWorkflowMessage error', () => {
it('should handle workflow API rejection', async () => {
vi.mocked(sendWorkflowMessage).mockRejectedValueOnce(new Error('API error'))
const { result } = renderHook(() => useTextGeneration(createProps({ isWorkflow: true })))
await act(async () => {
await result.current.handleSend()
// Wait for the catch handler to process
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(result.current.isResponding).toBe(false)
expect(Toast.notify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error', message: 'API error' }),
)
})
})
// controlStopResponding effect
describe('effects - controlStopResponding', () => {
it('should abort and reset state when controlStopResponding changes', async () => {
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
vi.mocked(sendCompletionMessage).mockImplementationOnce(
(async (_data: CompletionBody, { onData, getAbortController }: CompletionCbs) => {
getAbortController?.(new AbortController())
onData('chunk', true, { messageId: 'msg-1', taskId: 'task-1' })
}) as unknown as typeof sendCompletionMessage,
)
const { result, rerender } = renderHook(
(props: UseTextGenerationProps) => useTextGeneration(props),
{ initialProps: createProps({ controlStopResponding: 0 }) },
)
await act(async () => {
await result.current.handleSend()
})
expect(result.current.isResponding).toBe(true)
await act(async () => {
rerender(createProps({ controlStopResponding: Date.now() }))
})
expect(result.current.isResponding).toBe(false)
})
})
// onRunControlChange with active task
describe('effects - onRunControlChange with active task', () => {
it('should provide control object when responding with active task', async () => {
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
vi.mocked(sendWorkflowMessage).mockImplementationOnce(
(async (_data: WorkflowBody, callbacks: WorkflowCbs) => {
callbacks.onWorkflowStarted({ workflow_run_id: 'run-1', task_id: 'task-1' } as never)
}) as unknown as typeof sendWorkflowMessage,
)
const onRunControlChange = vi.fn()
const { result } = renderHook(() =>
useTextGeneration(createProps({ isWorkflow: true, onRunControlChange })),
)
await act(async () => {
await result.current.handleSend()
})
expect(onRunControlChange).toHaveBeenCalledWith(
expect.objectContaining({ onStop: expect.any(Function), isStopping: false }),
)
})
})
// Branch coverage: handleStop when already stopping
describe('handleStop - branch coverage', () => {
it('should do nothing when already stopping', async () => {
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
vi.mocked(sendWorkflowMessage).mockImplementationOnce(
(async (_data: WorkflowBody, callbacks: WorkflowCbs) => {
callbacks.onWorkflowStarted({ workflow_run_id: 'run-1', task_id: 'task-1' } as never)
}) as unknown as typeof sendWorkflowMessage,
)
// Make stopWorkflowMessage hang to keep isStopping=true
vi.mocked(stopWorkflowMessage).mockReturnValueOnce(new Promise(() => {}))
const { result } = renderHook(() => useTextGeneration(createProps({ isWorkflow: true })))
await act(async () => {
await result.current.handleSend()
})
// First stop sets isStopping=true
act(() => {
result.current.handleStop()
})
expect(result.current.isStopping).toBe(true)
// Second stop should be a no-op
await act(async () => {
await result.current.handleStop()
})
expect(stopWorkflowMessage).toHaveBeenCalledTimes(1)
})
})
// Branch coverage: onData with falsy/empty taskId
describe('sendCompletionMessage callbacks - branch coverage', () => {
it('should not set taskId when taskId is empty', async () => {
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
vi.mocked(sendCompletionMessage).mockImplementationOnce(
(async (_data: CompletionBody, { onData }: CompletionCbs) => {
onData('chunk', true, { messageId: 'msg-1', taskId: '' })
}) as unknown as typeof sendCompletionMessage,
)
const { result } = renderHook(() => useTextGeneration(createProps()))
await act(async () => {
await result.current.handleSend()
})
expect(result.current.currentTaskId).toBeNull()
})
it('should not override taskId when already set', async () => {
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
vi.mocked(sendCompletionMessage).mockImplementationOnce(
(async (_data: CompletionBody, { onData }: CompletionCbs) => {
onData('a', true, { messageId: 'msg-1', taskId: 'first-task' })
onData('b', false, { messageId: 'msg-1', taskId: 'second-task' })
}) as unknown as typeof sendCompletionMessage,
)
const { result } = renderHook(() => useTextGeneration(createProps()))
await act(async () => {
await result.current.handleSend()
})
// Should keep 'first-task', not override with 'second-task'
expect(result.current.currentTaskId).toBe('first-task')
})
})
// Branch coverage: promptConfig null
describe('handleSend - promptConfig null', () => {
it('should handle null promptConfig gracefully', async () => {
const props = createProps({ promptConfig: null })
const { result } = renderHook(() => useTextGeneration(props))
await act(async () => {
await result.current.handleSend()
})
expect(sendCompletionMessage).toHaveBeenCalled()
})
})
// Branch coverage: onCompleted before timeout (isEndRef=true skips timeout)
describe('sendCompletionMessage - timeout skip branch', () => {
it('should skip timeout when onCompleted fires before timeout resolves', async () => {
// Use default sleep mock (resolves immediately) - NOT overriding to never-resolve
const onCompleted = vi.fn()
vi.mocked(sendCompletionMessage).mockImplementationOnce(
(async (_data: CompletionBody, callbacks: CompletionCbs) => {
callbacks.onData('res', true, { messageId: 'msg-1', taskId: 'task-1' })
callbacks.onCompleted()
// isEndRef.current = true now, so timeout IIFE will skip
}) as unknown as typeof sendCompletionMessage,
)
const { result } = renderHook(() => useTextGeneration(createProps({ onCompleted })))
await act(async () => {
await result.current.handleSend()
})
expect(result.current.isResponding).toBe(false)
// onCompleted should be called once (from callback), not twice (timeout skipped)
expect(onCompleted).toHaveBeenCalledTimes(1)
expect(onCompleted).toHaveBeenCalledWith('res', undefined, true)
})
})
// Branch coverage: workflow error with non-Error object
describe('sendWorkflowMessage - non-Error rejection', () => {
it('should handle non-Error rejection via String()', async () => {
vi.mocked(sendWorkflowMessage).mockRejectedValueOnce('string error')
const { result } = renderHook(() => useTextGeneration(createProps({ isWorkflow: true })))
await act(async () => {
await result.current.handleSend()
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(result.current.isResponding).toBe(false)
expect(Toast.notify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error', message: 'string error' }),
)
})
})
// Branch coverage: hasUploadingFiles false branch
describe('handleSend - file upload branch', () => {
it('should proceed when files have upload_file_id (not uploading)', async () => {
const props = createProps({
completionFiles: [
{ transfer_method: TransferMethod.local_file, url: 'http://file', upload_file_id: 'f1' },
] as UseTextGenerationProps['completionFiles'],
})
const { result } = renderHook(() => useTextGeneration(props))
await act(async () => {
await result.current.handleSend()
})
expect(sendCompletionMessage).toHaveBeenCalled()
})
it('should proceed when files use remote_url transfer method', async () => {
const props = createProps({
completionFiles: [
{ transfer_method: TransferMethod.remote_url, url: 'http://remote' },
] as UseTextGenerationProps['completionFiles'],
})
const { result } = renderHook(() => useTextGeneration(props))
await act(async () => {
await result.current.handleSend()
})
expect(sendCompletionMessage).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,357 @@
import type { InputValueTypes } from '../../types'
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { PromptConfig } from '@/models/debug'
import type { AppSourceType } from '@/service/share'
import type { VisionFile, VisionSettings } from '@/types/app'
import { useBoolean } from 'ahooks'
import { t } from 'i18next'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
getProcessedFiles,
} from '@/app/components/base/file-uploader/utils'
import Toast from '@/app/components/base/toast'
import { TEXT_GENERATION_TIMEOUT_MS } from '@/config'
import {
sendCompletionMessage,
sendWorkflowMessage,
stopChatMessageResponding,
stopWorkflowMessage,
updateFeedback,
} from '@/service/share'
import { TransferMethod } from '@/types/app'
import { sleep } from '@/utils'
import { formatBooleanInputs } from '@/utils/model-config'
import { createWorkflowCallbacks } from './workflow-callbacks'
export type UseTextGenerationProps = {
isWorkflow: boolean
isCallBatchAPI: boolean
isPC: boolean
appSourceType: AppSourceType
appId?: string
promptConfig: PromptConfig | null
inputs: Record<string, InputValueTypes>
controlSend?: number
controlRetry?: number
controlStopResponding?: number
onShowRes: () => void
taskId?: number
onCompleted: (completionRes: string, taskId?: number, success?: boolean) => void
visionConfig: VisionSettings
completionFiles: VisionFile[]
onRunStart: () => void
onRunControlChange?: (control: { onStop: () => Promise<void> | void, isStopping: boolean } | null) => void
}
function hasUploadingFiles(files: VisionFile[]): boolean {
return files.some(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)
}
function processFileInputs(
processedInputs: Record<string, string | number | boolean | object>,
promptVariables: PromptConfig['prompt_variables'],
) {
promptVariables.forEach((variable) => {
const value = processedInputs[variable.key]
if (variable.type === 'file' && value && typeof value === 'object' && !Array.isArray(value))
processedInputs[variable.key] = getProcessedFiles([value as FileEntity])[0]
else if (variable.type === 'file-list' && Array.isArray(value) && value.length > 0)
processedInputs[variable.key] = getProcessedFiles(value as FileEntity[])
})
}
function prepareVisionFiles(files: VisionFile[]): VisionFile[] {
return files.map(item =>
item.transfer_method === TransferMethod.local_file ? { ...item, url: '' } : item,
)
}
export function useTextGeneration(props: UseTextGenerationProps) {
const {
isWorkflow,
isCallBatchAPI,
isPC,
appSourceType,
appId,
promptConfig,
inputs,
controlSend,
controlRetry,
controlStopResponding,
onShowRes,
taskId,
onCompleted,
visionConfig,
completionFiles,
onRunStart,
onRunControlChange,
} = props
const { notify } = Toast
const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false)
const [completionRes, doSetCompletionRes] = useState('')
const completionResRef = useRef('')
const setCompletionRes = (res: string) => {
completionResRef.current = res
doSetCompletionRes(res)
}
const getCompletionRes = () => completionResRef.current
const [workflowProcessData, doSetWorkflowProcessData] = useState<WorkflowProcess>()
const workflowProcessDataRef = useRef<WorkflowProcess | undefined>(undefined)
const setWorkflowProcessData = (data: WorkflowProcess) => {
workflowProcessDataRef.current = data
doSetWorkflowProcessData(data)
}
const getWorkflowProcessData = () => workflowProcessDataRef.current
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null)
const [isStopping, setIsStopping] = useState(false)
const abortControllerRef = useRef<AbortController | null>(null)
const isEndRef = useRef(false)
const isTimeoutRef = useRef(false)
const tempMessageIdRef = useRef('')
const resetRunState = useCallback(() => {
setCurrentTaskId(null) // eslint-disable-line react-hooks-extra/no-direct-set-state-in-use-effect
setIsStopping(false) // eslint-disable-line react-hooks-extra/no-direct-set-state-in-use-effect
abortControllerRef.current = null
onRunControlChange?.(null)
}, [onRunControlChange])
const [messageId, setMessageId] = useState<string | null>(null)
const [feedback, setFeedback] = useState<FeedbackType>({ rating: null })
const [controlClearMoreLikeThis, setControlClearMoreLikeThis] = useState(0)
const handleFeedback = async (fb: FeedbackType) => {
await updateFeedback(
{ url: `/messages/${messageId}/feedbacks`, body: { rating: fb.rating, content: fb.content } },
appSourceType,
appId,
)
setFeedback(fb)
}
const handleStop = useCallback(async () => {
if (!currentTaskId || isStopping)
return
setIsStopping(true)
try {
if (isWorkflow)
await stopWorkflowMessage(appId!, currentTaskId, appSourceType, appId || '')
else
await stopChatMessageResponding(appId!, currentTaskId, appSourceType, appId || '')
abortControllerRef.current?.abort()
}
catch (error) {
notify({ type: 'error', message: error instanceof Error ? error.message : String(error) })
}
finally {
setIsStopping(false)
}
}, [appId, currentTaskId, appSourceType, isStopping, isWorkflow, notify])
const checkCanSend = (): boolean => {
if (isCallBatchAPI)
return true
const promptVariables = promptConfig?.prompt_variables
if (!promptVariables?.length) {
if (hasUploadingFiles(completionFiles)) {
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
return false
}
return true
}
let hasEmptyInput = ''
const requiredVars = promptVariables?.filter(({ key, name, required, type }) => {
if (type === 'boolean' || type === 'checkbox')
return false
return (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
}) || []
requiredVars.forEach(({ key, name }) => {
if (hasEmptyInput)
return
if (!inputs[key])
hasEmptyInput = name
})
if (hasEmptyInput) {
notify({ type: 'error', message: t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput }) })
return false
}
if (hasUploadingFiles(completionFiles)) {
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
return false
}
return !hasEmptyInput
}
const handleSend = async () => {
if (isResponding) {
notify({ type: 'info', message: t('errorMessage.waitForResponse', { ns: 'appDebug' }) })
return
}
if (!checkCanSend())
return
const definedInputs = Object.fromEntries(
Object.entries(inputs).filter(([, v]) => v !== undefined),
) as Record<string, string | number | boolean | object>
const processedInputs = { ...formatBooleanInputs(promptConfig?.prompt_variables, definedInputs) }
processFileInputs(processedInputs, promptConfig?.prompt_variables ?? [])
const data: { inputs: Record<string, string | number | boolean | object>, files?: VisionFile[] } = { inputs: processedInputs }
if (visionConfig.enabled && completionFiles?.length > 0)
data.files = prepareVisionFiles(completionFiles)
setMessageId(null)
setFeedback({ rating: null })
setCompletionRes('')
resetRunState()
isEndRef.current = false
isTimeoutRef.current = false
tempMessageIdRef.current = ''
if (!isPC) {
onShowRes()
onRunStart()
}
setRespondingTrue()
;(async () => {
await sleep(TEXT_GENERATION_TIMEOUT_MS)
if (!isEndRef.current) {
setRespondingFalse()
onCompleted(getCompletionRes(), taskId, false)
resetRunState()
isTimeoutRef.current = true
}
})()
if (isWorkflow) {
const callbacks = createWorkflowCallbacks({
getProcessData: getWorkflowProcessData,
setProcessData: setWorkflowProcessData,
setCurrentTaskId,
setIsStopping,
getCompletionRes,
setCompletionRes,
setRespondingFalse,
resetRunState,
setMessageId,
isTimeoutRef,
isEndRef,
tempMessageIdRef,
taskId,
onCompleted,
notify,
t,
requestData: data,
})
sendWorkflowMessage(data, callbacks, appSourceType, appId).catch((error) => {
setRespondingFalse()
resetRunState()
notify({ type: 'error', message: error instanceof Error ? error.message : String(error) })
})
}
else {
let res: string[] = []
sendCompletionMessage(data, {
onData: (chunk: string, _isFirstMessage: boolean, { messageId: msgId, taskId: tId }) => {
tempMessageIdRef.current = msgId
if (tId && typeof tId === 'string' && tId.trim() !== '')
setCurrentTaskId(prev => prev ?? tId)
res.push(chunk)
setCompletionRes(res.join(''))
},
onCompleted: () => {
if (isTimeoutRef.current) {
notify({ type: 'warning', message: t('warningMessage.timeoutExceeded', { ns: 'appDebug' }) })
return
}
setRespondingFalse()
resetRunState()
setMessageId(tempMessageIdRef.current)
onCompleted(getCompletionRes(), taskId, true)
isEndRef.current = true
},
onMessageReplace: (messageReplace) => {
res = [messageReplace.answer]
setCompletionRes(res.join(''))
},
onError() {
if (isTimeoutRef.current) {
notify({ type: 'warning', message: t('warningMessage.timeoutExceeded', { ns: 'appDebug' }) })
return
}
setRespondingFalse()
resetRunState()
onCompleted(getCompletionRes(), taskId, false)
isEndRef.current = true
},
getAbortController: (abortController) => {
abortControllerRef.current = abortController
},
}, appSourceType, appId)
}
}
useEffect(() => {
const abortCurrentRequest = () => {
abortControllerRef.current?.abort()
}
if (controlStopResponding) {
abortCurrentRequest()
setRespondingFalse()
resetRunState()
}
return abortCurrentRequest
}, [controlStopResponding, resetRunState, setRespondingFalse])
useEffect(() => {
if (!onRunControlChange)
return
if (isResponding && currentTaskId)
onRunControlChange({ onStop: handleStop, isStopping })
else
onRunControlChange(null)
}, [currentTaskId, handleStop, isResponding, isStopping, onRunControlChange])
useEffect(() => {
if (controlSend) {
handleSend()
setControlClearMoreLikeThis(Date.now()) // eslint-disable-line react-hooks-extra/no-direct-set-state-in-use-effect
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controlSend])
useEffect(() => {
if (controlRetry)
handleSend()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controlRetry])
return {
isResponding,
completionRes,
workflowProcessData,
messageId,
feedback,
isStopping,
currentTaskId,
controlClearMoreLikeThis,
handleSend,
handleStop,
handleFeedback,
}
}

View File

@@ -0,0 +1,597 @@
import type { WorkflowCallbackDeps } from './workflow-callbacks'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { NodeTracing } from '@/types/workflow'
import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types'
import { createWorkflowCallbacks } from './workflow-callbacks'
vi.mock('@/app/components/base/file-uploader/utils', () => ({
getFilesInLogs: vi.fn(() => [{ name: 'file.png' }]),
}))
// Factory for a minimal NodeTracing-like object
const createTrace = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
id: 'trace-1',
index: 0,
predecessor_node_id: '',
node_id: 'node-1',
node_type: 'start',
title: 'Node',
status: NodeRunningStatus.Running,
...overrides,
} as NodeTracing)
// Factory for a base WorkflowProcess
const createProcess = (overrides: Partial<WorkflowProcess> = {}): WorkflowProcess => ({
status: WorkflowRunningStatus.Running,
tracing: [],
expand: false,
resultText: '',
...overrides,
})
// Factory for mock dependencies
function createMockDeps(overrides: Partial<WorkflowCallbackDeps> = {}): WorkflowCallbackDeps {
const process = createProcess()
return {
getProcessData: vi.fn(() => process),
setProcessData: vi.fn(),
setCurrentTaskId: vi.fn(),
setIsStopping: vi.fn(),
getCompletionRes: vi.fn(() => ''),
setCompletionRes: vi.fn(),
setRespondingFalse: vi.fn(),
resetRunState: vi.fn(),
setMessageId: vi.fn(),
isTimeoutRef: { current: false },
isEndRef: { current: false },
tempMessageIdRef: { current: '' },
onCompleted: vi.fn(),
notify: vi.fn(),
t: vi.fn((key: string) => key) as unknown as WorkflowCallbackDeps['t'],
requestData: { inputs: {} },
...overrides,
}
}
describe('createWorkflowCallbacks', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Workflow lifecycle start
describe('onWorkflowStarted', () => {
it('should initialize process data and set task id', () => {
const deps = createMockDeps()
const cb = createWorkflowCallbacks(deps)
cb.onWorkflowStarted({ workflow_run_id: 'run-1', task_id: 'task-1' } as never)
expect(deps.tempMessageIdRef.current).toBe('run-1')
expect(deps.setCurrentTaskId).toHaveBeenCalledWith('task-1')
expect(deps.setIsStopping).toHaveBeenCalledWith(false)
expect(deps.setProcessData).toHaveBeenCalledWith(
expect.objectContaining({ status: WorkflowRunningStatus.Running, tracing: [] }),
)
})
it('should default task_id to null when not provided', () => {
const deps = createMockDeps()
const cb = createWorkflowCallbacks(deps)
cb.onWorkflowStarted({ workflow_run_id: 'run-2' } as never)
expect(deps.setCurrentTaskId).toHaveBeenCalledWith(null)
})
})
// Shared group handlers (iteration & loop use the same logic)
describe('group handlers (iteration/loop)', () => {
it('onIterationStart should push a running trace', () => {
const deps = createMockDeps()
const cb = createWorkflowCallbacks(deps)
const trace = createTrace({ node_id: 'iter-node' })
cb.onIterationStart({ data: trace } as never)
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
expect(produced.expand).toBe(true)
expect(produced.tracing).toHaveLength(1)
expect(produced.tracing[0].node_id).toBe('iter-node')
expect(produced.tracing[0].status).toBe(NodeRunningStatus.Running)
})
it('onLoopStart should behave identically to onIterationStart', () => {
const deps = createMockDeps()
const cb = createWorkflowCallbacks(deps)
cb.onLoopStart({ data: createTrace({ node_id: 'loop-node' }) } as never)
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
expect(produced.tracing[0].node_id).toBe('loop-node')
})
it('onIterationFinish should replace trace entry', () => {
const existing = createTrace({ node_id: 'n1', execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'] })
const deps = createMockDeps({
getProcessData: vi.fn(() => createProcess({ tracing: [existing] })),
})
const cb = createWorkflowCallbacks(deps)
const updated = createTrace({ node_id: 'n1', execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'], error: 'fail' } as NodeTracing)
cb.onIterationFinish({ data: updated } as never)
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
expect(produced.tracing[0].expand).toBe(true) // error -> expand
})
})
// Node lifecycle
describe('onNodeStarted', () => {
it('should add a running trace for top-level nodes', () => {
const deps = createMockDeps()
const cb = createWorkflowCallbacks(deps)
cb.onNodeStarted({ data: createTrace({ node_id: 'top-node' }) } as never)
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
expect(produced.tracing).toHaveLength(1)
})
it('should skip nodes inside an iteration', () => {
const deps = createMockDeps()
const cb = createWorkflowCallbacks(deps)
cb.onNodeStarted({ data: createTrace({ iteration_id: 'iter-1' }) } as never)
expect(deps.setProcessData).not.toHaveBeenCalled()
})
it('should skip nodes inside a loop', () => {
const deps = createMockDeps()
const cb = createWorkflowCallbacks(deps)
cb.onNodeStarted({ data: createTrace({ loop_id: 'loop-1' }) } as never)
expect(deps.setProcessData).not.toHaveBeenCalled()
})
})
describe('onNodeFinished', () => {
it('should update existing trace entry', () => {
const trace = createTrace({ node_id: 'n1', execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'] })
const deps = createMockDeps({
getProcessData: vi.fn(() => createProcess({ tracing: [trace] })),
})
const cb = createWorkflowCallbacks(deps)
const finished = createTrace({
node_id: 'n1',
execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'],
status: NodeRunningStatus.Succeeded as NodeTracing['status'],
})
cb.onNodeFinished({ data: finished } as never)
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
expect(produced.tracing[0].status).toBe(NodeRunningStatus.Succeeded)
})
it('should skip nodes inside iteration or loop', () => {
const deps = createMockDeps()
const cb = createWorkflowCallbacks(deps)
cb.onNodeFinished({ data: createTrace({ iteration_id: 'i1' }) } as never)
cb.onNodeFinished({ data: createTrace({ loop_id: 'l1' }) } as never)
expect(deps.setProcessData).not.toHaveBeenCalled()
})
})
// Workflow completion
describe('onWorkflowFinished', () => {
it('should handle success with outputs', () => {
const deps = createMockDeps({ taskId: 1 })
const cb = createWorkflowCallbacks(deps)
cb.onWorkflowFinished({
data: { status: 'succeeded', outputs: { result: 'hello' } },
} as never)
expect(deps.setCompletionRes).toHaveBeenCalledWith({ result: 'hello' })
expect(deps.setRespondingFalse).toHaveBeenCalled()
expect(deps.resetRunState).toHaveBeenCalled()
expect(deps.onCompleted).toHaveBeenCalledWith('', 1, true)
expect(deps.isEndRef.current).toBe(true)
})
it('should handle success with single string output and set resultText', () => {
const deps = createMockDeps()
const cb = createWorkflowCallbacks(deps)
cb.onWorkflowFinished({
data: { status: 'succeeded', outputs: { text: 'response' } },
} as never)
// setProcessData called multiple times: succeeded status, then resultText
expect(deps.setProcessData).toHaveBeenCalledTimes(2)
})
it('should handle success without outputs', () => {
const deps = createMockDeps()
const cb = createWorkflowCallbacks(deps)
cb.onWorkflowFinished({
data: { status: 'succeeded', outputs: null },
} as never)
expect(deps.setCompletionRes).toHaveBeenCalledWith('')
})
it('should handle stopped status', () => {
const deps = createMockDeps()
const cb = createWorkflowCallbacks(deps)
cb.onWorkflowFinished({
data: { status: WorkflowRunningStatus.Stopped },
} as never)
expect(deps.onCompleted).toHaveBeenCalledWith('', undefined, false)
expect(deps.isEndRef.current).toBe(true)
})
it('should handle error status', () => {
const deps = createMockDeps()
const cb = createWorkflowCallbacks(deps)
cb.onWorkflowFinished({
data: { status: 'failed', error: 'Something broke' },
} as never)
expect(deps.notify).toHaveBeenCalledWith({ type: 'error', message: 'Something broke' })
expect(deps.onCompleted).toHaveBeenCalledWith('', undefined, false)
})
it('should skip processing when timeout has already occurred', () => {
const deps = createMockDeps()
deps.isTimeoutRef.current = true
const cb = createWorkflowCallbacks(deps)
cb.onWorkflowFinished({
data: { status: 'succeeded', outputs: { text: 'late' } },
} as never)
expect(deps.notify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'warning' }),
)
expect(deps.onCompleted).not.toHaveBeenCalled()
})
})
// Streaming text handlers
describe('text handlers', () => {
it('onTextChunk should append text to resultText', () => {
const deps = createMockDeps({
getProcessData: vi.fn(() => createProcess({ resultText: 'hello' })),
})
const cb = createWorkflowCallbacks(deps)
cb.onTextChunk({ data: { text: ' world' } } as never)
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
expect(produced.resultText).toBe('hello world')
})
it('onTextReplace should replace resultText entirely', () => {
const deps = createMockDeps({
getProcessData: vi.fn(() => createProcess({ resultText: 'old' })),
})
const cb = createWorkflowCallbacks(deps)
cb.onTextReplace({ data: { text: 'new' } } as never)
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
expect(produced.resultText).toBe('new')
})
})
// handleGroupNext with valid node_id (covers findTrace)
describe('handleGroupNext', () => {
it('should push empty details to matching group when node_id exists', () => {
const existingTrace = createTrace({
node_id: 'group-node',
execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'],
details: [[]],
} as Partial<NodeTracing>)
const deps = createMockDeps({
getProcessData: vi.fn(() => createProcess({ tracing: [existingTrace] })),
requestData: { inputs: {}, node_id: 'group-node', execution_metadata: { parallel_id: 'p1' } },
})
const cb = createWorkflowCallbacks(deps)
cb.onIterationNext()
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
expect(produced.tracing[0].details).toHaveLength(2)
expect(produced.expand).toBe(true)
})
it('should handle no matching group gracefully', () => {
const deps = createMockDeps({
getProcessData: vi.fn(() => createProcess({ tracing: [] })),
requestData: { inputs: {}, node_id: 'nonexistent' },
})
const cb = createWorkflowCallbacks(deps)
// Should not throw even when no matching trace is found
cb.onLoopNext()
expect(deps.setProcessData).toHaveBeenCalled()
})
})
// markNodesStopped edge cases
describe('markNodesStopped', () => {
it('should handle undefined tracing gracefully', () => {
const deps = createMockDeps({
getProcessData: vi.fn(() => ({
status: WorkflowRunningStatus.Running,
expand: false,
resultText: '',
} as unknown as WorkflowProcess)),
})
const cb = createWorkflowCallbacks(deps)
cb.onWorkflowFinished({
data: { status: WorkflowRunningStatus.Stopped },
} as never)
expect(deps.setProcessData).toHaveBeenCalled()
expect(deps.onCompleted).toHaveBeenCalledWith('', undefined, false)
})
it('should recursively mark running/waiting nodes and nested structures as stopped', () => {
const nestedTrace = createTrace({ node_id: 'nested', status: NodeRunningStatus.Running })
const retryTrace = createTrace({ node_id: 'retry', status: NodeRunningStatus.Waiting })
const parallelChild = createTrace({ node_id: 'p-child', status: NodeRunningStatus.Running })
const parentTrace = createTrace({
node_id: 'parent',
status: NodeRunningStatus.Running,
details: [[nestedTrace]],
retryDetail: [retryTrace],
parallelDetail: { children: [parallelChild] },
} as Partial<NodeTracing>)
const deps = createMockDeps({
getProcessData: vi.fn(() => createProcess({ tracing: [parentTrace] })),
})
const cb = createWorkflowCallbacks(deps)
cb.onWorkflowFinished({
data: { status: WorkflowRunningStatus.Stopped },
} as never)
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
expect(produced.tracing[0].status).toBe(NodeRunningStatus.Stopped)
expect(produced.tracing[0].details![0][0].status).toBe(NodeRunningStatus.Stopped)
expect(produced.tracing[0].retryDetail![0].status).toBe(NodeRunningStatus.Stopped)
const parallel = produced.tracing[0].parallelDetail as { children: NodeTracing[] }
expect(parallel.children[0].status).toBe(NodeRunningStatus.Stopped)
})
it('should not change status of already succeeded nodes', () => {
const succeededTrace = createTrace({
node_id: 'done',
status: NodeRunningStatus.Succeeded,
})
const deps = createMockDeps({
getProcessData: vi.fn(() => createProcess({ tracing: [succeededTrace] })),
})
const cb = createWorkflowCallbacks(deps)
cb.onWorkflowFinished({
data: { status: WorkflowRunningStatus.Stopped },
} as never)
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
expect(produced.tracing[0].status).toBe(NodeRunningStatus.Succeeded)
})
it('should handle trace with no nested details/retryDetail/parallelDetail', () => {
const simpleTrace = createTrace({ node_id: 'simple', status: NodeRunningStatus.Running })
const deps = createMockDeps({
getProcessData: vi.fn(() => createProcess({ tracing: [simpleTrace] })),
})
const cb = createWorkflowCallbacks(deps)
cb.onWorkflowFinished({
data: { status: WorkflowRunningStatus.Stopped },
} as never)
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
expect(produced.tracing[0].status).toBe(NodeRunningStatus.Stopped)
})
})
// Branch coverage: handleGroupNext early return
describe('handleGroupNext - early return', () => {
it('should return early when requestData has no node_id', () => {
const deps = createMockDeps({
requestData: { inputs: {} }, // no node_id
})
const cb = createWorkflowCallbacks(deps)
cb.onIterationNext()
expect(deps.setProcessData).not.toHaveBeenCalled()
})
})
// Branch coverage: onNodeFinished edge cases
describe('onNodeFinished - branch coverage', () => {
it('should preserve existing extras when updating trace', () => {
const trace = createTrace({
node_id: 'n1',
execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'],
extras: { key: 'val' },
} as Partial<NodeTracing>)
const deps = createMockDeps({
getProcessData: vi.fn(() => createProcess({ tracing: [trace] })),
})
const cb = createWorkflowCallbacks(deps)
cb.onNodeFinished({
data: createTrace({
node_id: 'n1',
execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'],
status: NodeRunningStatus.Succeeded as NodeTracing['status'],
}),
} as never)
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
expect(produced.tracing[0].extras).toEqual({ key: 'val' })
})
it('should not add extras when existing trace has no extras', () => {
const trace = createTrace({
node_id: 'n1',
execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'],
})
const deps = createMockDeps({
getProcessData: vi.fn(() => createProcess({ tracing: [trace] })),
})
const cb = createWorkflowCallbacks(deps)
cb.onNodeFinished({
data: createTrace({
node_id: 'n1',
execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'],
}),
} as never)
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
expect(produced.tracing[0]).not.toHaveProperty('extras')
})
it('should do nothing when trace is not found (idx === -1)', () => {
const deps = createMockDeps({
getProcessData: vi.fn(() => createProcess({ tracing: [] })),
})
const cb = createWorkflowCallbacks(deps)
cb.onNodeFinished({
data: createTrace({ node_id: 'nonexistent' }),
} as never)
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
expect(produced.tracing).toHaveLength(0)
})
})
// Branch coverage: handleGroupFinish without error
describe('handleGroupFinish - branch coverage', () => {
it('should set expand=false when no error', () => {
const existing = createTrace({
node_id: 'n1',
execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'],
})
const deps = createMockDeps({
getProcessData: vi.fn(() => createProcess({ tracing: [existing] })),
})
const cb = createWorkflowCallbacks(deps)
cb.onLoopFinish({
data: createTrace({
node_id: 'n1',
execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'],
}),
} as never)
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
expect(produced.tracing[0].expand).toBe(false)
})
})
// Branch coverage: handleWorkflowEnd without error
describe('handleWorkflowEnd - branch coverage', () => {
it('should not notify when no error message', () => {
const deps = createMockDeps()
const cb = createWorkflowCallbacks(deps)
cb.onWorkflowFinished({
data: { status: WorkflowRunningStatus.Stopped },
} as never)
expect(deps.notify).not.toHaveBeenCalled()
})
})
// Branch coverage: findTraceIndex matching via parallel_id vs execution_metadata
describe('findTrace matching', () => {
it('should match trace via parallel_id field', () => {
const trace = createTrace({
node_id: 'n1',
parallel_id: 'p1',
} as Partial<NodeTracing>)
const deps = createMockDeps({
getProcessData: vi.fn(() => createProcess({ tracing: [trace] })),
})
const cb = createWorkflowCallbacks(deps)
cb.onNodeFinished({
data: createTrace({
node_id: 'n1',
execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'],
status: NodeRunningStatus.Succeeded as NodeTracing['status'],
}),
} as never)
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
expect(produced.tracing[0].status).toBe(NodeRunningStatus.Succeeded)
})
it('should not match when both parallel_id fields differ', () => {
const trace = createTrace({
node_id: 'group-node',
execution_metadata: { parallel_id: 'other' } as NodeTracing['execution_metadata'],
parallel_id: 'also-other',
details: [[]],
} as Partial<NodeTracing>)
const deps = createMockDeps({
getProcessData: vi.fn(() => createProcess({ tracing: [trace] })),
requestData: { inputs: {}, node_id: 'group-node', execution_metadata: { parallel_id: 'target' } },
})
const cb = createWorkflowCallbacks(deps)
cb.onIterationNext()
// group not found, details unchanged
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
expect(produced.tracing[0].details).toHaveLength(1)
})
})
// Branch coverage: onWorkflowFinished success with multiple output keys
describe('onWorkflowFinished - output branches', () => {
it('should not set resultText when outputs have multiple keys', () => {
const deps = createMockDeps()
const cb = createWorkflowCallbacks(deps)
cb.onWorkflowFinished({
data: { status: 'succeeded', outputs: { key1: 'val1', key2: 'val2' } },
} as never)
// setProcessData called once (for succeeded status), not twice (no resultText)
expect(deps.setProcessData).toHaveBeenCalledTimes(1)
})
it('should not set resultText when single key is not a string', () => {
const deps = createMockDeps()
const cb = createWorkflowCallbacks(deps)
cb.onWorkflowFinished({
data: { status: 'succeeded', outputs: { data: { nested: true } } },
} as never)
expect(deps.setProcessData).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -0,0 +1,237 @@
import type { TFunction } from 'i18next'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { VisionFile } from '@/types/app'
import type { NodeTracing, WorkflowFinishedResponse } from '@/types/workflow'
import { produce } from 'immer'
import {
getFilesInLogs,
} from '@/app/components/base/file-uploader/utils'
import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types'
type WorkflowFinishedData = WorkflowFinishedResponse['data']
type TraceItem = WorkflowProcess['tracing'][number]
function findTraceIndex(
tracing: WorkflowProcess['tracing'],
nodeId: string,
parallelId?: string,
): number {
return tracing.findIndex(item =>
item.node_id === nodeId
&& (item.execution_metadata?.parallel_id === parallelId || item.parallel_id === parallelId),
)
}
function findTrace(
tracing: WorkflowProcess['tracing'],
nodeId: string,
parallelId?: string,
): TraceItem | undefined {
return tracing.find(item =>
item.node_id === nodeId
&& (item.execution_metadata?.parallel_id === parallelId || item.parallel_id === parallelId),
)
}
function markNodesStopped(traces?: WorkflowProcess['tracing']) {
if (!traces)
return
const mark = (trace: TraceItem) => {
if ([NodeRunningStatus.Running, NodeRunningStatus.Waiting].includes(trace.status as NodeRunningStatus))
trace.status = NodeRunningStatus.Stopped
trace.details?.forEach(group => group.forEach(mark))
trace.retryDetail?.forEach(mark)
trace.parallelDetail?.children?.forEach(mark)
}
traces.forEach(mark)
}
export type WorkflowCallbackDeps = {
getProcessData: () => WorkflowProcess | undefined
setProcessData: (data: WorkflowProcess) => void
setCurrentTaskId: (id: string | null) => void
setIsStopping: (v: boolean) => void
getCompletionRes: () => string
setCompletionRes: (res: string) => void
setRespondingFalse: () => void
resetRunState: () => void
setMessageId: (id: string | null) => void
isTimeoutRef: { current: boolean }
isEndRef: { current: boolean }
tempMessageIdRef: { current: string }
taskId?: number
onCompleted: (completionRes: string, taskId?: number, success?: boolean) => void
notify: (options: { type: 'error' | 'info' | 'success' | 'warning', message: string }) => void
t: TFunction
// The outer request data object passed to sendWorkflowMessage.
// Used by group next handlers to match traces (mirrors original closure behavior).
requestData: { inputs: Record<string, string | number | boolean | object>, files?: VisionFile[], node_id?: string, execution_metadata?: { parallel_id?: string } }
}
export function createWorkflowCallbacks(deps: WorkflowCallbackDeps) {
const {
getProcessData,
setProcessData,
setCurrentTaskId,
setIsStopping,
getCompletionRes,
setCompletionRes,
setRespondingFalse,
resetRunState,
setMessageId,
isTimeoutRef,
isEndRef,
tempMessageIdRef,
taskId,
onCompleted,
notify,
t,
requestData,
} = deps
const updateProcessData = (updater: (draft: WorkflowProcess) => void) => {
setProcessData(produce(getProcessData()!, updater))
}
const handleGroupStart = ({ data }: { data: NodeTracing }) => {
updateProcessData((draft) => {
draft.expand = true
draft.tracing!.push({ ...data, status: NodeRunningStatus.Running, expand: true })
})
}
const handleGroupNext = () => {
if (!requestData.node_id)
return
updateProcessData((draft) => {
draft.expand = true
const group = findTrace(
draft.tracing,
requestData.node_id!,
requestData.execution_metadata?.parallel_id,
)
group?.details!.push([])
})
}
const handleGroupFinish = ({ data }: { data: NodeTracing }) => {
updateProcessData((draft) => {
draft.expand = true
const idx = findTraceIndex(draft.tracing, data.node_id, data.execution_metadata?.parallel_id)
draft.tracing[idx] = { ...data, expand: !!data.error }
})
}
const handleWorkflowEnd = (status: WorkflowRunningStatus, error?: string) => {
if (error)
notify({ type: 'error', message: error })
updateProcessData((draft) => {
draft.status = status
markNodesStopped(draft.tracing)
})
setRespondingFalse()
resetRunState()
onCompleted(getCompletionRes(), taskId, false)
isEndRef.current = true
}
return {
onWorkflowStarted: ({ workflow_run_id, task_id }: { workflow_run_id: string, task_id?: string }) => {
tempMessageIdRef.current = workflow_run_id
setCurrentTaskId(task_id || null)
setIsStopping(false)
setProcessData({
status: WorkflowRunningStatus.Running,
tracing: [],
expand: false,
resultText: '',
})
},
onIterationStart: handleGroupStart,
onIterationNext: handleGroupNext,
onIterationFinish: handleGroupFinish,
onLoopStart: handleGroupStart,
onLoopNext: handleGroupNext,
onLoopFinish: handleGroupFinish,
onNodeStarted: ({ data }: { data: NodeTracing }) => {
if (data.iteration_id || data.loop_id)
return
updateProcessData((draft) => {
draft.expand = true
draft.tracing!.push({ ...data, status: NodeRunningStatus.Running, expand: true })
})
},
onNodeFinished: ({ data }: { data: NodeTracing }) => {
if (data.iteration_id || data.loop_id)
return
updateProcessData((draft) => {
const idx = findTraceIndex(draft.tracing!, data.node_id, data.execution_metadata?.parallel_id)
if (idx > -1 && draft.tracing) {
draft.tracing[idx] = {
...(draft.tracing[idx].extras ? { extras: draft.tracing[idx].extras } : {}),
...data,
expand: !!data.error,
}
}
})
},
onWorkflowFinished: ({ data }: { data: WorkflowFinishedData }) => {
if (isTimeoutRef.current) {
notify({ type: 'warning', message: t('warningMessage.timeoutExceeded', { ns: 'appDebug' }) })
return
}
if (data.status === WorkflowRunningStatus.Stopped) {
handleWorkflowEnd(WorkflowRunningStatus.Stopped)
return
}
if (data.error) {
handleWorkflowEnd(WorkflowRunningStatus.Failed, data.error)
return
}
updateProcessData((draft) => {
draft.status = WorkflowRunningStatus.Succeeded
// eslint-disable-next-line ts/no-explicit-any
draft.files = getFilesInLogs(data.outputs || []) as any[]
})
if (data.outputs) {
setCompletionRes(data.outputs)
const keys = Object.keys(data.outputs)
if (keys.length === 1 && typeof data.outputs[keys[0]] === 'string') {
updateProcessData((draft) => {
draft.resultText = data.outputs[keys[0]]
})
}
}
else {
setCompletionRes('')
}
setRespondingFalse()
resetRunState()
setMessageId(tempMessageIdRef.current)
onCompleted(getCompletionRes(), taskId, true)
isEndRef.current = true
},
onTextChunk: (params: { data: { text: string } }) => {
updateProcessData((draft) => {
draft.resultText += params.data.text
})
},
onTextReplace: (params: { data: { text: string } }) => {
updateProcessData((draft) => {
draft.resultText = params.data.text
})
},
}
}

View File

@@ -0,0 +1,245 @@
import type { IResultProps } from './index'
import { render, screen } from '@testing-library/react'
import { AppSourceType } from '@/service/share'
import Result from './index'
// Mock the custom hook to control state
const mockHandleSend = vi.fn()
const mockHandleStop = vi.fn()
const mockHandleFeedback = vi.fn()
let hookReturnValue = {
isResponding: false,
completionRes: '',
workflowProcessData: undefined as IResultProps['isWorkflow'] extends true ? object : undefined,
messageId: null as string | null,
feedback: { rating: null as string | null },
isStopping: false,
currentTaskId: null as string | null,
controlClearMoreLikeThis: 0,
handleSend: mockHandleSend,
handleStop: mockHandleStop,
handleFeedback: mockHandleFeedback,
}
vi.mock('./hooks/use-text-generation', () => ({
useTextGeneration: () => hookReturnValue,
}))
vi.mock('i18next', () => ({
t: (key: string) => key,
}))
// Mock complex external component to keep tests focused
vi.mock('@/app/components/app/text-generate/item', () => ({
default: ({ content, isWorkflow, taskId, isLoading }: {
content: string
isWorkflow: boolean
taskId?: string
isLoading: boolean
}) => (
<div
data-testid="text-generation-res"
data-content={content}
data-workflow={String(isWorkflow)}
data-task-id={taskId ?? ''}
data-loading={String(isLoading)}
/>
),
}))
vi.mock('@/app/components/share/text-generation/no-data', () => ({
default: () => <div data-testid="no-data" />,
}))
// Factory for default props
const createProps = (overrides: Partial<IResultProps> = {}): IResultProps => ({
isWorkflow: false,
isCallBatchAPI: false,
isPC: true,
isMobile: false,
appSourceType: AppSourceType.webApp,
appId: 'app-1',
isError: false,
isShowTextToSpeech: false,
promptConfig: { prompt_template: '', prompt_variables: [] },
moreLikeThisEnabled: false,
inputs: {},
onShowRes: vi.fn(),
handleSaveMessage: vi.fn(),
onCompleted: vi.fn(),
visionConfig: { enabled: false } as IResultProps['visionConfig'],
completionFiles: [],
siteInfo: null,
onRunStart: vi.fn(),
...overrides,
})
describe('Result', () => {
beforeEach(() => {
vi.clearAllMocks()
hookReturnValue = {
isResponding: false,
completionRes: '',
workflowProcessData: undefined,
messageId: null,
feedback: { rating: null },
isStopping: false,
currentTaskId: null,
controlClearMoreLikeThis: 0,
handleSend: mockHandleSend,
handleStop: mockHandleStop,
handleFeedback: mockHandleFeedback,
}
})
// Empty state rendering
describe('empty state', () => {
it('should show NoData when not batch and no completion data', () => {
render(<Result {...createProps()} />)
expect(screen.getByTestId('no-data')).toBeInTheDocument()
expect(screen.queryByTestId('text-generation-res')).not.toBeInTheDocument()
})
it('should show NoData when workflow mode has no process data', () => {
render(<Result {...createProps({ isWorkflow: true })} />)
expect(screen.getByTestId('no-data')).toBeInTheDocument()
})
})
// Loading state rendering
describe('loading state', () => {
it('should show loading spinner when responding but no data yet', () => {
hookReturnValue.isResponding = true
hookReturnValue.completionRes = ''
const { container } = render(<Result {...createProps()} />)
// Loading area renders a spinner
expect(container.querySelector('.items-center.justify-center')).toBeInTheDocument()
expect(screen.queryByTestId('no-data')).not.toBeInTheDocument()
expect(screen.queryByTestId('text-generation-res')).not.toBeInTheDocument()
})
it('should not show loading in batch mode even when responding', () => {
hookReturnValue.isResponding = true
hookReturnValue.completionRes = ''
render(<Result {...createProps({ isCallBatchAPI: true })} />)
// Batch mode skips loading state and goes to TextGenerationRes
expect(screen.getByTestId('text-generation-res')).toBeInTheDocument()
})
})
// Result rendering
describe('result rendering', () => {
it('should render TextGenerationRes when completion data exists', () => {
hookReturnValue.completionRes = 'Generated output'
render(<Result {...createProps()} />)
const res = screen.getByTestId('text-generation-res')
expect(res).toBeInTheDocument()
expect(res.dataset.content).toBe('Generated output')
})
it('should render TextGenerationRes for workflow with process data', () => {
hookReturnValue.workflowProcessData = { status: 'running', tracing: [] } as never
render(<Result {...createProps({ isWorkflow: true })} />)
const res = screen.getByTestId('text-generation-res')
expect(res.dataset.workflow).toBe('true')
})
it('should format batch taskId with leading zero for single digit', () => {
hookReturnValue.completionRes = 'batch result'
render(<Result {...createProps({ isCallBatchAPI: true, taskId: 3 })} />)
expect(screen.getByTestId('text-generation-res').dataset.taskId).toBe('03')
})
it('should format batch taskId without leading zero for double digit', () => {
hookReturnValue.completionRes = 'batch result'
render(<Result {...createProps({ isCallBatchAPI: true, taskId: 12 })} />)
expect(screen.getByTestId('text-generation-res').dataset.taskId).toBe('12')
})
it('should show loading in TextGenerationRes for batch mode while responding', () => {
hookReturnValue.isResponding = true
hookReturnValue.completionRes = ''
render(<Result {...createProps({ isCallBatchAPI: true })} />)
expect(screen.getByTestId('text-generation-res').dataset.loading).toBe('true')
})
})
// Stop button
describe('stop button', () => {
it('should show stop button when responding with active task', () => {
hookReturnValue.isResponding = true
hookReturnValue.completionRes = 'data'
hookReturnValue.currentTaskId = 'task-1'
render(<Result {...createProps()} />)
expect(screen.getByText('operation.stopResponding')).toBeInTheDocument()
})
it('should hide stop button when hideInlineStopButton is true', () => {
hookReturnValue.isResponding = true
hookReturnValue.completionRes = 'data'
hookReturnValue.currentTaskId = 'task-1'
render(<Result {...createProps({ hideInlineStopButton: true })} />)
expect(screen.queryByText('operation.stopResponding')).not.toBeInTheDocument()
})
it('should hide stop button when not responding', () => {
hookReturnValue.completionRes = 'data'
hookReturnValue.currentTaskId = 'task-1'
render(<Result {...createProps()} />)
expect(screen.queryByText('operation.stopResponding')).not.toBeInTheDocument()
})
it('should show spinner icon when stopping', () => {
hookReturnValue.isResponding = true
hookReturnValue.completionRes = 'data'
hookReturnValue.currentTaskId = 'task-1'
hookReturnValue.isStopping = true
const { container } = render(<Result {...createProps()} />)
expect(container.querySelector('.animate-spin')).toBeInTheDocument()
})
it('should align stop button to end on PC, center on mobile', () => {
hookReturnValue.isResponding = true
hookReturnValue.completionRes = 'data'
hookReturnValue.currentTaskId = 'task-1'
const { container, rerender } = render(<Result {...createProps({ isPC: true })} />)
expect(container.querySelector('.justify-end')).toBeInTheDocument()
rerender(<Result {...createProps({ isPC: false })} />)
expect(container.querySelector('.justify-center')).toBeInTheDocument()
})
})
// Memo
describe('memoization', () => {
it('should be wrapped with React.memo', () => {
expect((Result as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})
})

View File

@@ -1,34 +1,19 @@
'use client'
import type { FC } from 'react'
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { InputValueTypes } from '../types'
import type { PromptConfig } from '@/models/debug'
import type { SiteInfo } from '@/models/share'
import type { AppSourceType } from '@/service/share'
import type { VisionFile, VisionSettings } from '@/types/app'
import { RiLoader2Line } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { t } from 'i18next'
import { produce } from 'immer'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import TextGenerationRes from '@/app/components/app/text-generate/item'
import Button from '@/app/components/base/button'
import {
getFilesInLogs,
getProcessedFiles,
} from '@/app/components/base/file-uploader/utils'
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import NoData from '@/app/components/share/text-generation/no-data'
import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types'
import { TEXT_GENERATION_TIMEOUT_MS } from '@/config'
import { sendCompletionMessage, sendWorkflowMessage, stopChatMessageResponding, stopWorkflowMessage, updateFeedback } from '@/service/share'
import { TransferMethod } from '@/types/app'
import { sleep } from '@/utils'
import { formatBooleanInputs } from '@/utils/model-config'
import { useTextGeneration } from './hooks/use-text-generation'
export type IResultProps = {
isWorkflow: boolean
@@ -41,7 +26,7 @@ export type IResultProps = {
isShowTextToSpeech: boolean
promptConfig: PromptConfig | null
moreLikeThisEnabled: boolean
inputs: Record<string, any>
inputs: Record<string, InputValueTypes>
controlSend?: number
controlRetry?: number
controlStopResponding?: number
@@ -57,492 +42,61 @@ export type IResultProps = {
hideInlineStopButton?: boolean
}
const Result: FC<IResultProps> = ({
isWorkflow,
isCallBatchAPI,
isPC,
isMobile,
appSourceType,
appId,
isError,
isShowTextToSpeech,
promptConfig,
moreLikeThisEnabled,
inputs,
controlSend,
controlRetry,
controlStopResponding,
onShowRes,
handleSaveMessage,
taskId,
onCompleted,
visionConfig,
completionFiles,
siteInfo,
onRunStart,
onRunControlChange,
hideInlineStopButton = false,
}) => {
const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false)
const [completionRes, doSetCompletionRes] = useState<string>('')
const completionResRef = useRef<string>('')
const setCompletionRes = (res: string) => {
completionResRef.current = res
doSetCompletionRes(res)
}
const getCompletionRes = () => completionResRef.current
const [workflowProcessData, doSetWorkflowProcessData] = useState<WorkflowProcess>()
const workflowProcessDataRef = useRef<WorkflowProcess | undefined>(undefined)
const setWorkflowProcessData = (data: WorkflowProcess) => {
workflowProcessDataRef.current = data
doSetWorkflowProcessData(data)
}
const getWorkflowProcessData = () => workflowProcessDataRef.current
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null)
const [isStopping, setIsStopping] = useState(false)
const abortControllerRef = useRef<AbortController | null>(null)
const resetRunState = useCallback(() => {
setCurrentTaskId(null)
setIsStopping(false)
abortControllerRef.current = null
onRunControlChange?.(null)
}, [onRunControlChange])
const Result: FC<IResultProps> = (props) => {
const {
isWorkflow,
isCallBatchAPI,
isPC,
isMobile,
appSourceType,
appId,
isError,
isShowTextToSpeech,
moreLikeThisEnabled,
handleSaveMessage,
taskId,
siteInfo,
hideInlineStopButton = false,
} = props
useEffect(() => {
const abortCurrentRequest = () => {
abortControllerRef.current?.abort()
}
const {
isResponding,
completionRes,
workflowProcessData,
messageId,
feedback,
isStopping,
currentTaskId,
controlClearMoreLikeThis,
handleSend,
handleStop,
handleFeedback,
} = useTextGeneration(props)
if (controlStopResponding) {
abortCurrentRequest()
setRespondingFalse()
resetRunState()
}
// Determine content state using a unified check
const hasData = isWorkflow ? !!workflowProcessData : !!completionRes
const isLoadingState = !isCallBatchAPI && isResponding && !hasData
const isEmptyState = !isCallBatchAPI && !hasData
return abortCurrentRequest
}, [controlStopResponding, resetRunState, setRespondingFalse])
const { notify } = Toast
const isNoData = !completionRes
const [messageId, setMessageId] = useState<string | null>(null)
const [feedback, setFeedback] = useState<FeedbackType>({
rating: null,
})
const handleFeedback = async (feedback: FeedbackType) => {
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, appSourceType, appId)
setFeedback(feedback)
if (isLoadingState) {
return (
<div className="flex h-full w-full items-center justify-center">
<Loading type="area" />
</div>
)
}
const logError = (message: string) => {
notify({ type: 'error', message })
}
if (isEmptyState)
return <NoData />
const handleStop = useCallback(async () => {
if (!currentTaskId || isStopping)
return
setIsStopping(true)
try {
if (isWorkflow)
await stopWorkflowMessage(appId!, currentTaskId, appSourceType, appId || '')
else
await stopChatMessageResponding(appId!, currentTaskId, appSourceType, appId || '')
abortControllerRef.current?.abort()
}
catch (error) {
const message = error instanceof Error ? error.message : String(error)
notify({ type: 'error', message })
}
finally {
setIsStopping(false)
}
}, [appId, currentTaskId, appSourceType, appId, isStopping, isWorkflow, notify])
useEffect(() => {
if (!onRunControlChange)
return
if (isResponding && currentTaskId) {
onRunControlChange({
onStop: handleStop,
isStopping,
})
}
else {
onRunControlChange(null)
}
}, [currentTaskId, handleStop, isResponding, isStopping, onRunControlChange])
const checkCanSend = () => {
// batch will check outer
if (isCallBatchAPI)
return true
const prompt_variables = promptConfig?.prompt_variables
if (!prompt_variables || prompt_variables?.length === 0) {
if (completionFiles.find(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) {
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
return false
}
return true
}
let hasEmptyInput = ''
const requiredVars = prompt_variables?.filter(({ key, name, required, type }) => {
if (type === 'boolean' || type === 'checkbox')
return false // boolean/checkbox input is not required
const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
return res
}) || [] // compatible with old version
requiredVars.forEach(({ key, name }) => {
if (hasEmptyInput)
return
if (!inputs[key])
hasEmptyInput = name
})
if (hasEmptyInput) {
logError(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput }))
return false
}
if (completionFiles.find(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) {
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
return false
}
return !hasEmptyInput
}
const handleSend = async () => {
if (isResponding) {
notify({ type: 'info', message: t('errorMessage.waitForResponse', { ns: 'appDebug' }) })
return false
}
if (!checkCanSend())
return
// Process inputs: convert file entities to API format
const processedInputs = { ...formatBooleanInputs(promptConfig?.prompt_variables, inputs) }
promptConfig?.prompt_variables.forEach((variable) => {
const value = processedInputs[variable.key]
if (variable.type === 'file' && value && typeof value === 'object' && !Array.isArray(value)) {
// Convert single file entity to API format
processedInputs[variable.key] = getProcessedFiles([value as FileEntity])[0]
}
else if (variable.type === 'file-list' && Array.isArray(value) && value.length > 0) {
// Convert file entity array to API format
processedInputs[variable.key] = getProcessedFiles(value as FileEntity[])
}
})
const data: Record<string, any> = {
inputs: processedInputs,
}
if (visionConfig.enabled && completionFiles && completionFiles?.length > 0) {
data.files = completionFiles.map((item) => {
if (item.transfer_method === TransferMethod.local_file) {
return {
...item,
url: '',
}
}
return item
})
}
setMessageId(null)
setFeedback({
rating: null,
})
setCompletionRes('')
resetRunState()
let res: string[] = []
let tempMessageId = ''
if (!isPC) {
onShowRes()
onRunStart()
}
setRespondingTrue()
let isEnd = false
let isTimeout = false;
(async () => {
await sleep(TEXT_GENERATION_TIMEOUT_MS)
if (!isEnd) {
setRespondingFalse()
onCompleted(getCompletionRes(), taskId, false)
resetRunState()
isTimeout = true
}
})()
if (isWorkflow) {
sendWorkflowMessage(
data,
{
onWorkflowStarted: ({ workflow_run_id, task_id }) => {
tempMessageId = workflow_run_id
setCurrentTaskId(task_id || null)
setIsStopping(false)
setWorkflowProcessData({
status: WorkflowRunningStatus.Running,
tracing: [],
expand: false,
resultText: '',
})
},
onIterationStart: ({ data }) => {
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.expand = true
draft.tracing!.push({
...data,
status: NodeRunningStatus.Running,
expand: true,
})
}))
},
onIterationNext: () => {
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.expand = true
const iterations = draft.tracing.find(item => item.node_id === data.node_id
&& (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
iterations?.details!.push([])
}))
},
onIterationFinish: ({ data }) => {
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.expand = true
const iterationsIndex = draft.tracing.findIndex(item => item.node_id === data.node_id
&& (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
draft.tracing[iterationsIndex] = {
...data,
expand: !!data.error,
}
}))
},
onLoopStart: ({ data }) => {
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.expand = true
draft.tracing!.push({
...data,
status: NodeRunningStatus.Running,
expand: true,
})
}))
},
onLoopNext: () => {
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.expand = true
const loops = draft.tracing.find(item => item.node_id === data.node_id
&& (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
loops?.details!.push([])
}))
},
onLoopFinish: ({ data }) => {
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.expand = true
const loopsIndex = draft.tracing.findIndex(item => item.node_id === data.node_id
&& (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
draft.tracing[loopsIndex] = {
...data,
expand: !!data.error,
}
}))
},
onNodeStarted: ({ data }) => {
if (data.iteration_id)
return
if (data.loop_id)
return
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.expand = true
draft.tracing!.push({
...data,
status: NodeRunningStatus.Running,
expand: true,
})
}))
},
onNodeFinished: ({ data }) => {
if (data.iteration_id)
return
if (data.loop_id)
return
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
const currentIndex = draft.tracing!.findIndex(trace => trace.node_id === data.node_id
&& (trace.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || trace.parallel_id === data.execution_metadata?.parallel_id))
if (currentIndex > -1 && draft.tracing) {
draft.tracing[currentIndex] = {
...(draft.tracing[currentIndex].extras
? { extras: draft.tracing[currentIndex].extras }
: {}),
...data,
expand: !!data.error,
}
}
}))
},
onWorkflowFinished: ({ data }) => {
if (isTimeout) {
notify({ type: 'warning', message: t('warningMessage.timeoutExceeded', { ns: 'appDebug' }) })
return
}
const workflowStatus = data.status as WorkflowRunningStatus | undefined
const markNodesStopped = (traces?: WorkflowProcess['tracing']) => {
if (!traces)
return
const markTrace = (trace: WorkflowProcess['tracing'][number]) => {
if ([NodeRunningStatus.Running, NodeRunningStatus.Waiting].includes(trace.status as NodeRunningStatus))
trace.status = NodeRunningStatus.Stopped
trace.details?.forEach(detailGroup => detailGroup.forEach(markTrace))
trace.retryDetail?.forEach(markTrace)
trace.parallelDetail?.children?.forEach(markTrace)
}
traces.forEach(markTrace)
}
if (workflowStatus === WorkflowRunningStatus.Stopped) {
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.status = WorkflowRunningStatus.Stopped
markNodesStopped(draft.tracing)
}))
setRespondingFalse()
resetRunState()
onCompleted(getCompletionRes(), taskId, false)
isEnd = true
return
}
if (data.error) {
notify({ type: 'error', message: data.error })
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.status = WorkflowRunningStatus.Failed
markNodesStopped(draft.tracing)
}))
setRespondingFalse()
resetRunState()
onCompleted(getCompletionRes(), taskId, false)
isEnd = true
return
}
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.status = WorkflowRunningStatus.Succeeded
draft.files = getFilesInLogs(data.outputs || []) as any[]
}))
if (!data.outputs) {
setCompletionRes('')
}
else {
setCompletionRes(data.outputs)
const isStringOutput = Object.keys(data.outputs).length === 1 && typeof data.outputs[Object.keys(data.outputs)[0]] === 'string'
if (isStringOutput) {
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.resultText = data.outputs[Object.keys(data.outputs)[0]]
}))
}
}
setRespondingFalse()
resetRunState()
setMessageId(tempMessageId)
onCompleted(getCompletionRes(), taskId, true)
isEnd = true
},
onTextChunk: (params) => {
const { data: { text } } = params
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.resultText += text
}))
},
onTextReplace: (params) => {
const { data: { text } } = params
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.resultText = text
}))
},
},
appSourceType,
appId,
).catch((error) => {
setRespondingFalse()
resetRunState()
const message = error instanceof Error ? error.message : String(error)
notify({ type: 'error', message })
})
}
else {
sendCompletionMessage(data, {
onData: (data: string, _isFirstMessage: boolean, { messageId, taskId }) => {
tempMessageId = messageId
if (taskId && typeof taskId === 'string' && taskId.trim() !== '')
setCurrentTaskId(prev => prev ?? taskId)
res.push(data)
setCompletionRes(res.join(''))
},
onCompleted: () => {
if (isTimeout) {
notify({ type: 'warning', message: t('warningMessage.timeoutExceeded', { ns: 'appDebug' }) })
return
}
setRespondingFalse()
resetRunState()
setMessageId(tempMessageId)
onCompleted(getCompletionRes(), taskId, true)
isEnd = true
},
onMessageReplace: (messageReplace) => {
res = [messageReplace.answer]
setCompletionRes(res.join(''))
},
onError() {
if (isTimeout) {
notify({ type: 'warning', message: t('warningMessage.timeoutExceeded', { ns: 'appDebug' }) })
return
}
setRespondingFalse()
resetRunState()
onCompleted(getCompletionRes(), taskId, false)
isEnd = true
},
getAbortController: (abortController) => {
abortControllerRef.current = abortController
},
}, appSourceType, appId)
}
}
const [controlClearMoreLikeThis, setControlClearMoreLikeThis] = useState(0)
useEffect(() => {
if (controlSend) {
handleSend()
setControlClearMoreLikeThis(Date.now())
}
}, [controlSend])
useEffect(() => {
if (controlRetry)
handleSend()
}, [controlRetry])
const renderTextGenerationRes = () => (
return (
<>
{!hideInlineStopButton && isResponding && currentTaskId && (
<div className={`mb-3 flex ${isPC ? 'justify-end' : 'justify-center'}`}>
<Button
variant="secondary"
disabled={isStopping}
onClick={handleStop}
>
{
isStopping
? <RiLoader2Line className="mr-[5px] h-3.5 w-3.5 animate-spin" />
: <StopCircle className="mr-[5px] h-3.5 w-3.5" />
}
<Button variant="secondary" disabled={isStopping} onClick={handleStop}>
{isStopping
? <RiLoader2Line className="mr-[5px] h-3.5 w-3.5 animate-spin" />
: <StopCircle className="mr-[5px] h-3.5 w-3.5" />}
<span className="text-xs font-normal">{t('operation.stopResponding', { ns: 'appDebug' })}</span>
</Button>
</div>
@@ -571,37 +125,6 @@ const Result: FC<IResultProps> = ({
/>
</>
)
return (
<>
{!isCallBatchAPI && !isWorkflow && (
(isResponding && !completionRes)
? (
<div className="flex h-full w-full items-center justify-center">
<Loading type="area" />
</div>
)
: (
<>
{(isNoData)
? <NoData />
: renderTextGenerationRes()}
</>
)
)}
{!isCallBatchAPI && isWorkflow && (
(isResponding && !workflowProcessData)
? (
<div className="flex h-full w-full items-center justify-center">
<Loading type="area" />
</div>
)
: !workflowProcessData
? <NoData />
: renderTextGenerationRes()
)}
{isCallBatchAPI && renderTextGenerationRes()}
</>
)
}
export default React.memo(Result)

View File

@@ -1,5 +1,7 @@
import type { ChangeEvent, FC, FormEvent } from 'react'
import type { InputValueTypes } from '../types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { FileUploadConfigResponse } from '@/models/common'
import type { PromptConfig } from '@/models/debug'
import type { SiteInfo } from '@/models/share'
import type { VisionFile, VisionSettings } from '@/types/app'
@@ -8,7 +10,7 @@ import {
RiPlayLargeLine,
} from '@remixicon/react'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
@@ -50,7 +52,7 @@ const RunOnce: FC<IRunOnceProps> = ({
const { t } = useTranslation()
const media = useBreakpoints()
const isPC = media === MediaType.pc
const [isInitialized, setIsInitialized] = useState(false)
const isInitializedRef = React.useRef(false)
const onClear = () => {
const newInputs: Record<string, InputValueTypes> = {}
@@ -80,15 +82,16 @@ const RunOnce: FC<IRunOnceProps> = ({
runControl?.onStop?.()
}, [isRunning, runControl])
const handleInputsChange = useCallback((newInputs: Record<string, any>) => {
const handleInputsChange = useCallback((newInputs: Record<string, InputValueTypes>) => {
onInputsChange(newInputs)
inputsRef.current = newInputs
}, [onInputsChange, inputsRef])
useEffect(() => {
if (isInitialized)
if (isInitializedRef.current)
return
const newInputs: Record<string, any> = {}
isInitializedRef.current = true
const newInputs: Record<string, InputValueTypes> = {}
promptConfig.prompt_variables.forEach((item) => {
if (item.type === 'select')
newInputs[item.key] = item.default
@@ -106,7 +109,6 @@ const RunOnce: FC<IRunOnceProps> = ({
newInputs[item.key] = undefined
})
onInputsChange(newInputs)
setIsInitialized(true)
}, [promptConfig.prompt_variables, onInputsChange])
return (
@@ -114,7 +116,7 @@ const RunOnce: FC<IRunOnceProps> = ({
<section>
{/* input form */}
<form onSubmit={onSubmit}>
{(inputs === null || inputs === undefined || Object.keys(inputs).length === 0) || !isInitialized
{Object.keys(inputs).length === 0
? null
: promptConfig.prompt_variables.filter(item => item.hide !== true).map(item => (
<div className="mt-4 w-full" key={item.key}>
@@ -169,22 +171,21 @@ const RunOnce: FC<IRunOnceProps> = ({
)}
{item.type === 'file' && (
<FileUploaderInAttachmentWrapper
value={(inputs[item.key] && typeof inputs[item.key] === 'object') ? [inputs[item.key]] : []}
value={(inputs[item.key] && typeof inputs[item.key] === 'object') ? [inputs[item.key] as FileEntity] : []}
onChange={(files) => { handleInputsChange({ ...inputsRef.current, [item.key]: files[0] }) }}
fileConfig={{
...item.config,
fileUploadConfig: (visionConfig as any).fileUploadConfig,
fileUploadConfig: (visionConfig as VisionSettings & { fileUploadConfig?: FileUploadConfigResponse }).fileUploadConfig,
}}
/>
)}
{item.type === 'file-list' && (
<FileUploaderInAttachmentWrapper
value={Array.isArray(inputs[item.key]) ? inputs[item.key] : []}
value={Array.isArray(inputs[item.key]) ? inputs[item.key] as FileEntity[] : []}
onChange={(files) => { handleInputsChange({ ...inputsRef.current, [item.key]: files }) }}
fileConfig={{
...item.config,
// eslint-disable-next-line ts/no-explicit-any
fileUploadConfig: (visionConfig as any).fileUploadConfig,
fileUploadConfig: (visionConfig as VisionSettings & { fileUploadConfig?: FileUploadConfigResponse }).fileUploadConfig,
}}
/>
)}

View File

@@ -1,5 +1,9 @@
type TaskParam = {
inputs: Record<string, string | boolean | undefined>
import type { FileEntity } from '@/app/components/base/file-uploader/types'
export type InputValueTypes = string | boolean | number | string[] | FileEntity | FileEntity[] | Record<string, unknown> | undefined
export type TaskParam = {
inputs: Record<string, string | undefined>
}
export type Task = {
@@ -14,6 +18,3 @@ export enum TaskStatus {
completed = 'completed',
failed = 'failed',
}
// eslint-disable-next-line ts/no-explicit-any
export type InputValueTypes = string | boolean | number | string[] | object | undefined | any

View File

@@ -2,7 +2,9 @@ import consistentPlaceholders from './rules/consistent-placeholders.js'
import noAsAnyInT from './rules/no-as-any-in-t.js'
import noExtraKeys from './rules/no-extra-keys.js'
import noLegacyNamespacePrefix from './rules/no-legacy-namespace-prefix.js'
import noVersionPrefix from './rules/no-version-prefix.js'
import requireNsOption from './rules/require-ns-option.js'
import validI18nKeys from './rules/valid-i18n-keys.js'
/** @type {import('eslint').ESLint.Plugin} */
const plugin = {
@@ -15,7 +17,9 @@ const plugin = {
'no-as-any-in-t': noAsAnyInT,
'no-extra-keys': noExtraKeys,
'no-legacy-namespace-prefix': noLegacyNamespacePrefix,
'no-version-prefix': noVersionPrefix,
'require-ns-option': requireNsOption,
'valid-i18n-keys': validI18nKeys,
},
}

View File

@@ -0,0 +1,45 @@
const DEPENDENCY_KEYS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
const VERSION_PREFIXES = ['^', '~']
/** @type {import('eslint').Rule.RuleModule} */
export default {
meta: {
type: 'problem',
docs: {
description: `Ensure package.json dependencies do not use version prefixes (${VERSION_PREFIXES.join(' or ')})`,
},
fixable: 'code',
},
create(context) {
const { filename } = context
if (!filename.endsWith('package.json'))
return {}
const selector = `JSONProperty:matches(${DEPENDENCY_KEYS.map(k => `[key.value="${k}"]`).join(', ')}) > JSONObjectExpression > JSONProperty`
return {
[selector](node) {
const versionNode = node.value
if (versionNode && versionNode.type === 'JSONLiteral' && typeof versionNode.value === 'string') {
const version = versionNode.value
const foundPrefix = VERSION_PREFIXES.find(prefix => version.startsWith(prefix))
if (foundPrefix) {
const packageName = node.key.value || node.key.name
const cleanVersion = version.substring(1)
const canAutoFix = /^\d+\.\d+\.\d+$/.test(cleanVersion)
context.report({
node: versionNode,
message: `Dependency "${packageName}" has version prefix "${foundPrefix}" that should be removed (found: "${version}", expected: "${cleanVersion}")`,
fix: canAutoFix
? fixer => fixer.replaceText(versionNode, `"${cleanVersion}"`)
: undefined,
})
}
}
},
}
},
}

View File

@@ -0,0 +1,61 @@
import { cleanJsonText } from '../utils.js'
/** @type {import('eslint').Rule.RuleModule} */
export default {
meta: {
type: 'problem',
docs: {
description: 'Ensure i18n JSON keys are flat and valid as object paths',
},
},
create(context) {
return {
Program(node) {
const { filename, sourceCode } = context
if (!filename.endsWith('.json'))
return
let json
try {
json = JSON.parse(cleanJsonText(sourceCode.text))
}
catch {
context.report({
node,
message: 'Invalid JSON format',
})
return
}
const keys = Object.keys(json)
const keyPrefixes = new Set()
for (const key of keys) {
if (key.includes('.')) {
const parts = key.split('.')
for (let i = 1; i < parts.length; i++) {
const prefix = parts.slice(0, i).join('.')
if (keys.includes(prefix)) {
context.report({
node,
message: `Invalid key structure: '${key}' conflicts with '${prefix}'`,
})
}
keyPrefixes.add(prefix)
}
}
}
for (const key of keys) {
if (keyPrefixes.has(key)) {
context.report({
node,
message: `Invalid key structure: '${key}' is a prefix of another key`,
})
}
}
},
}
},
}

View File

@@ -3141,14 +3141,6 @@
"count": 1
}
},
"app/components/share/text-generation/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
"ts/no-explicit-any": {
"count": 8
}
},
"app/components/share/text-generation/menu-dropdown.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@@ -3159,19 +3151,6 @@
"count": 1
}
},
"app/components/share/text-generation/result/header.tsx": {
"tailwindcss/no-unnecessary-whitespace": {
"count": 3
}
},
"app/components/share/text-generation/result/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 3
},
"ts/no-explicit-any": {
"count": 3
}
},
"app/components/share/text-generation/run-batch/csv-reader/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 2
@@ -3182,14 +3161,6 @@
"count": 2
}
},
"app/components/share/text-generation/run-once/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
},
"app/components/share/utils.ts": {
"ts/no-explicit-any": {
"count": 2

View File

@@ -2,7 +2,6 @@
import antfu from '@antfu/eslint-config'
import pluginQuery from '@tanstack/eslint-plugin-query'
import tailwindcss from 'eslint-plugin-better-tailwindcss'
import hyoban from 'eslint-plugin-hyoban'
import sonar from 'eslint-plugin-sonarjs'
import storybook from 'eslint-plugin-storybook'
import dify from './eslint-rules/index.js'
@@ -80,47 +79,7 @@ export default antfu(
},
},
{
name: 'dify/custom/setup',
plugins: {
dify,
hyoban,
},
},
{
files: ['**/*.tsx'],
rules: {
'hyoban/prefer-tailwind-icons': ['warn', {
prefix: 'i-',
propMappings: {
size: 'size',
width: 'w',
height: 'h',
},
libraries: [
{
prefix: 'i-custom-',
source: '^@/app/components/base/icons/src/(?<set>(?:public|vender)(?:/.*)?)$',
name: '^(?<name>.*)$',
},
{
source: '^@remixicon/react$',
name: '^(?<set>Ri)(?<name>.+)$',
},
{
source: '^@(?<set>heroicons)/react/24/outline$',
name: '^(?<name>.*)Icon$',
},
{
source: '^@(?<set>heroicons)/react/24/(?<variant>solid)$',
name: '^(?<name>.*)Icon$',
},
{
source: '^@(?<set>heroicons)/react/(?<variant>\\d+/(?:solid|outline))$',
name: '^(?<name>.*)Icon$',
},
],
}],
},
plugins: { dify },
},
{
files: ['i18n/**/*.json'],
@@ -129,7 +88,7 @@ export default antfu(
'max-lines': 'off',
'jsonc/sort-keys': 'error',
'hyoban/i18n-flat-key': 'error',
'dify/valid-i18n-keys': 'error',
'dify/no-extra-keys': 'error',
'dify/consistent-placeholders': 'error',
},
@@ -137,7 +96,7 @@ export default antfu(
{
files: ['**/package.json'],
rules: {
'hyoban/no-dependency-version-prefix': 'error',
'dify/no-version-prefix': 'error',
},
},
)

View File

@@ -31,8 +31,8 @@
"build": "next build",
"build:docker": "next build && node scripts/optimize-standalone.js",
"start": "node ./scripts/copy-and-start.mjs",
"lint": "eslint --cache --concurrency=auto",
"lint:ci": "eslint --cache --concurrency 2",
"lint": "eslint --cache --concurrency=\"auto\"",
"lint:ci": "eslint --cache --concurrency 3",
"lint:fix": "pnpm lint --fix",
"lint:quiet": "pnpm lint --quiet",
"lint:complexity": "pnpm lint --rule 'complexity: [error, {max: 15}]' --quiet",
@@ -166,10 +166,7 @@
"devDependencies": {
"@antfu/eslint-config": "7.2.0",
"@chromatic-com/storybook": "5.0.0",
"@egoist/tailwindcss-icons": "1.9.2",
"@eslint-react/eslint-plugin": "2.9.4",
"@iconify-json/heroicons": "1.2.3",
"@iconify-json/ri": "1.2.7",
"@mdx-js/loader": "3.1.1",
"@mdx-js/react": "3.1.1",
"@next/bundle-analyzer": "16.1.5",
@@ -216,14 +213,12 @@
"cross-env": "10.1.0",
"esbuild": "0.27.2",
"eslint": "9.39.2",
"eslint-plugin-better-tailwindcss": "https://pkg.pr.new/hyoban/eslint-plugin-better-tailwindcss@c0161c7",
"eslint-plugin-hyoban": "0.10.1",
"eslint-plugin-better-tailwindcss": "4.1.1",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-react-refresh": "0.5.0",
"eslint-plugin-sonarjs": "3.0.6",
"eslint-plugin-storybook": "10.2.6",
"husky": "9.1.7",
"iconify-import-svg": "0.1.1",
"jsdom": "27.3.0",
"jsdom-testing-mocks": "1.16.0",
"knip": "5.78.0",

390
web/pnpm-lock.yaml generated
View File

@@ -372,18 +372,9 @@ importers:
'@chromatic-com/storybook':
specifier: 5.0.0
version: 5.0.0(storybook@10.2.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
'@egoist/tailwindcss-icons':
specifier: 1.9.2
version: 1.9.2(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))
'@eslint-react/eslint-plugin':
specifier: 2.9.4
version: 2.9.4(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@iconify-json/heroicons':
specifier: 1.2.3
version: 1.2.3
'@iconify-json/ri':
specifier: 1.2.7
version: 1.2.7
'@mdx-js/loader':
specifier: 3.1.1
version: 3.1.1(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
@@ -523,11 +514,8 @@ importers:
specifier: 9.39.2
version: 9.39.2(jiti@1.21.7)
eslint-plugin-better-tailwindcss:
specifier: https://pkg.pr.new/hyoban/eslint-plugin-better-tailwindcss@c0161c7
version: https://pkg.pr.new/hyoban/eslint-plugin-better-tailwindcss@c0161c7(eslint@9.39.2(jiti@1.21.7))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))(typescript@5.9.3)
eslint-plugin-hyoban:
specifier: 0.10.1
version: 0.10.1(eslint@9.39.2(jiti@1.21.7))
specifier: 4.1.1
version: 4.1.1(eslint@9.39.2(jiti@1.21.7))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))(typescript@5.9.3)
eslint-plugin-react-hooks:
specifier: 7.0.1
version: 7.0.1(eslint@9.39.2(jiti@1.21.7))
@@ -543,9 +531,6 @@ importers:
husky:
specifier: 9.1.7
version: 9.1.7
iconify-import-svg:
specifier: 0.1.1
version: 0.1.1
jsdom:
specifier: 27.3.0
version: 27.3.0(canvas@3.2.1)
@@ -748,9 +733,6 @@ packages:
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@antfu/utils@8.1.1':
resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==}
'@asamuzakjp/css-color@4.1.1':
resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==}
@@ -940,11 +922,6 @@ packages:
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
engines: {node: '>=10.0.0'}
'@egoist/tailwindcss-icons@1.9.2':
resolution: {integrity: sha512-I6XsSykmhu2cASg5Hp/ICLsJ/K/1aXPaSKjgbWaNp2xYnb4We/arWMmkhhV+9CglOFCUbqx0A3mM2kWV32ZIhw==}
peerDependencies:
tailwindcss: '*'
'@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
@@ -1322,21 +1299,9 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@iconify-json/heroicons@1.2.3':
resolution: {integrity: sha512-n+vmCEgTesRsOpp5AB5ILB6srsgsYK+bieoQBNlafvoEhjVXLq8nIGN4B0v/s4DUfa0dOrjwE/cKJgIKdJXOEg==}
'@iconify-json/ri@1.2.7':
resolution: {integrity: sha512-j/Fkb8GlWY5y/zLj1BGxWRtDzuJFrI7562zLw+iQVEykieBgew43+r8qAvtSajvb75MfUIHjsNOYQPRD8FfLfw==}
'@iconify/tools@4.2.0':
resolution: {integrity: sha512-WRxPva/ipxYkqZd1+CkEAQmd86dQmrwH0vwK89gmp2Kh2WyyVw57XbPng0NehP3x4V1LzLsXUneP1uMfTMZmUA==}
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
'@iconify/utils@2.3.0':
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
'@iconify/utils@3.1.0':
resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==}
@@ -1594,10 +1559,6 @@ packages:
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
'@isaacs/fs-minipass@4.0.1':
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
engines: {node: '>=18.0.0'}
'@joshwooding/vite-plugin-react-docgen-typescript@0.6.3':
resolution: {integrity: sha512-9TGZuAX+liGkNKkwuo3FYJu7gHWT0vkBcf7GkOe7s7fmC19XwH/4u5u7sDIFrMooe558ORcmuBvBz7Ur5PlbHw==}
peerDependencies:
@@ -3018,10 +2979,6 @@ packages:
peerDependencies:
'@testing-library/dom': '>=7.21.4'
'@trysound/sax@0.2.0':
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
engines: {node: '>=10.13.0'}
'@tsslint/cli@3.0.2':
resolution: {integrity: sha512-8lyZcDEs86zitz0wZ5QRdswY6xGz8j+WL11baN4rlpwahtPgYatujpYV5gpoKeyMAyerlNTdQh6u2LUJLoLNyQ==}
engines: {node: '>=22.6.0'}
@@ -3278,9 +3235,6 @@ packages:
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
'@types/zen-observable@0.8.3':
resolution: {integrity: sha512-fbF6oTd4sGGy0xjHPKAt+eS2CrxJ3+6gQ3FGcBoIJR2TLAyCkCyI8JqZNy+FeON0AhVgNJoUumVoZQjBFUqHkw==}
@@ -3796,9 +3750,6 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer-crc32@0.2.13:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -3891,13 +3842,6 @@ packages:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
cheerio-select@2.1.0:
resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
cheerio@1.2.0:
resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==}
engines: {node: '>=20.18.1'}
chevrotain-allstar@0.3.1:
resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==}
peerDependencies:
@@ -3917,10 +3861,6 @@ packages:
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
chromatic@13.3.5:
resolution: {integrity: sha512-MzPhxpl838qJUo0A55osCF2ifwPbjcIPeElr1d4SHcjnHoIcg7l1syJDrAYK/a+PcCBrOGi06jPNpQAln5hWgw==}
hasBin: true
@@ -4079,25 +4019,10 @@ packages:
css-mediaquery@0.1.2:
resolution: {integrity: sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==}
css-select@5.2.2:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
css-tree@2.2.1:
resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
css-tree@2.3.1:
resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
css-tree@3.1.0:
resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
css-what@6.2.2:
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
engines: {node: '>= 6'}
css.escape@1.5.1:
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
@@ -4109,10 +4034,6 @@ packages:
cssfontparser@1.2.1:
resolution: {integrity: sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==}
csso@5.0.5:
resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
cssstyle@5.3.7:
resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==}
engines: {node: '>=20'}
@@ -4375,25 +4296,12 @@ packages:
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
dompurify@3.2.7:
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
dompurify@3.3.0:
resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
@@ -4444,9 +4352,6 @@ packages:
resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==}
engines: {node: '>=14'}
encoding-sniffer@0.2.1:
resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==}
end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
@@ -4454,10 +4359,6 @@ packages:
resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
engines: {node: '>=10.13.0'}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
@@ -4552,9 +4453,8 @@ packages:
peerDependencies:
eslint: '*'
eslint-plugin-better-tailwindcss@https://pkg.pr.new/hyoban/eslint-plugin-better-tailwindcss@c0161c7:
resolution: {tarball: https://pkg.pr.new/hyoban/eslint-plugin-better-tailwindcss@c0161c7}
version: 4.1.1
eslint-plugin-better-tailwindcss@4.1.1:
resolution: {integrity: sha512-ctw461TGJi8iM0P01mNVjSW7jeUAdyUgmrrd59np5/VxqX50nayMbwKZkfmjWpP1PWOqlh4CSMOH/WW6ICWmJw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23.0.0}
peerDependencies:
eslint: ^7.0.0 || ^8.0.0 || ^9.0.0
@@ -4577,11 +4477,6 @@ packages:
peerDependencies:
eslint: '>=8'
eslint-plugin-hyoban@0.10.1:
resolution: {integrity: sha512-fQhK6COgm4branMemO0c52XNRYMLNW19jAhjBLS90Lup+QdzTEsFF9xDq8UA7JhID2wF+wDXBjTjEqUux6ZjfA==}
peerDependencies:
eslint: '*'
eslint-plugin-import-lite@0.5.0:
resolution: {integrity: sha512-7uBvxuQj+VlYmZSYSHcm33QgmZnvMLP2nQiWaLtjhJ5x1zKcskOqjolL+dJC13XY+ktQqBgidAnnQMELfRaXQg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4864,11 +4759,6 @@ packages:
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
extract-zip@2.0.1:
resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
engines: {node: '>= 10.17.0'}
hasBin: true
fast-content-type-parse@2.0.1:
resolution: {integrity: sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==}
@@ -4904,9 +4794,6 @@ packages:
fd-package-json@2.0.0:
resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==}
fd-slicer@1.1.0:
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -5001,10 +4888,6 @@ packages:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'}
get-stream@5.2.0:
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
engines: {node: '>=8'}
get-stream@8.0.1:
resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==}
engines: {node: '>=16'}
@@ -5163,9 +5046,6 @@ packages:
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
htmlparser2@10.1.0:
resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
@@ -5194,9 +5074,6 @@ packages:
typescript:
optional: true
iconify-import-svg@0.1.1:
resolution: {integrity: sha512-8HwZIe3ZqCfZ68NZUCnHN264fwHWhE+O5hWDfBtOEY7u1V97yOogHaoXGRLOx17M0c8+z65xYqJXA16ieCYIwA==}
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
@@ -5729,12 +5606,6 @@ packages:
mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
mdn-data@2.0.28:
resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
mdn-data@2.0.30:
resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
@@ -5916,10 +5787,6 @@ packages:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
minizlib@3.1.0:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
@@ -6137,12 +6004,6 @@ packages:
parse-statements@1.0.11:
resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==}
parse5-htmlparser2-tree-adapter@7.1.0:
resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
parse5-parser-stream@7.1.2:
resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==}
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
@@ -6193,9 +6054,6 @@ packages:
resolution: {integrity: sha512-MbkAjpwka/dMHaCfQ75RY1FXX3IewBVu6NGZOcxerRFlaBiIkZmUoR0jotX5VUzYZEXAGzSFtknWs5xRKliXPA==}
engines: {node: '>=18'}
pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -7005,11 +6863,6 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
svgo@3.3.2:
resolution: {integrity: sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==}
engines: {node: '>=14.0.0'}
hasBin: true
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
@@ -7047,10 +6900,6 @@ packages:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
tar@7.5.7:
resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==}
engines: {node: '>=18'}
terser-webpack-plugin@5.3.16:
resolution: {integrity: sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==}
engines: {node: '>= 10.13.0'}
@@ -7234,10 +7083,6 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici@7.20.0:
resolution: {integrity: sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==}
engines: {node: '>=20.18.1'}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -7655,10 +7500,6 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yallist@5.0.0:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
yaml-eslint-parser@2.0.0:
resolution: {integrity: sha512-h0uDm97wvT2bokfwwTmY6kJ1hp6YDFL0nRHwNKz8s/VD1FH/vvZjAKoMUE+un0eaYBSG7/c6h+lJTP+31tjgTw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
@@ -7668,9 +7509,6 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
yauzl@2.10.0:
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
yjs@13.6.29:
resolution: {integrity: sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
@@ -7949,8 +7787,6 @@ snapshots:
package-manager-detector: 1.6.0
tinyexec: 1.0.2
'@antfu/utils@8.1.1': {}
'@asamuzakjp/css-color@4.1.1':
dependencies:
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
@@ -8204,11 +8040,6 @@ snapshots:
'@discoveryjs/json-ext@0.5.7': {}
'@egoist/tailwindcss-icons@1.9.2(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@iconify/utils': 3.1.0
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
'@emnapi/core@1.8.1':
dependencies:
'@emnapi/wasi-threads': 1.1.0
@@ -8580,43 +8411,8 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@iconify-json/heroicons@1.2.3':
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/ri@1.2.7':
dependencies:
'@iconify/types': 2.0.0
'@iconify/tools@4.2.0':
dependencies:
'@iconify/types': 2.0.0
'@iconify/utils': 2.3.0
cheerio: 1.2.0
domhandler: 5.0.3
extract-zip: 2.0.1
local-pkg: 1.1.2
pathe: 2.0.3
svgo: 3.3.2
tar: 7.5.7
transitivePeerDependencies:
- supports-color
'@iconify/types@2.0.0': {}
'@iconify/utils@2.3.0':
dependencies:
'@antfu/install-pkg': 1.1.0
'@antfu/utils': 8.1.1
'@iconify/types': 2.0.0
debug: 4.4.3
globals: 15.15.0
kolorist: 1.8.0
local-pkg: 1.1.2
mlly: 1.8.0
transitivePeerDependencies:
- supports-color
'@iconify/utils@3.1.0':
dependencies:
'@antfu/install-pkg': 1.1.0
@@ -8810,10 +8606,6 @@ snapshots:
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
'@isaacs/fs-minipass@4.0.1':
dependencies:
minipass: 7.1.2
'@joshwooding/vite-plugin-react-docgen-typescript@0.6.3(typescript@5.9.3)(vite@7.3.1(@types/node@18.15.0)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
glob: 11.1.0
@@ -10347,8 +10139,6 @@ snapshots:
dependencies:
'@testing-library/dom': 10.4.1
'@trysound/sax@0.2.0': {}
'@tsslint/cli@3.0.2(@tsslint/compat-eslint@3.0.2(jiti@1.21.7)(typescript@5.9.3))(typescript@5.9.3)':
dependencies:
'@clack/prompts': 0.8.2
@@ -10646,11 +10436,6 @@ snapshots:
'@types/uuid@10.0.0': {}
'@types/yauzl@2.10.3':
dependencies:
'@types/node': 18.15.0
optional: true
'@types/zen-observable@0.8.3': {}
'@typescript-eslint/eslint-plugin@8.53.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
@@ -11323,8 +11108,6 @@ snapshots:
node-releases: 2.0.27
update-browserslist-db: 1.2.3(browserslist@4.28.1)
buffer-crc32@0.2.13: {}
buffer-from@1.1.2:
optional: true
@@ -11400,29 +11183,6 @@ snapshots:
check-error@2.1.3: {}
cheerio-select@2.1.0:
dependencies:
boolbase: 1.0.0
css-select: 5.2.2
css-what: 6.2.2
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
cheerio@1.2.0:
dependencies:
cheerio-select: 2.1.0
dom-serializer: 2.0.0
domhandler: 5.0.3
domutils: 3.2.2
encoding-sniffer: 0.2.1
htmlparser2: 10.1.0
parse5: 7.3.0
parse5-htmlparser2-tree-adapter: 7.1.0
parse5-parser-stream: 7.1.2
undici: 7.20.0
whatwg-mimetype: 4.0.0
chevrotain-allstar@0.3.1(chevrotain@11.0.3):
dependencies:
chevrotain: 11.0.3
@@ -11456,8 +11216,6 @@ snapshots:
chownr@1.1.4:
optional: true
chownr@3.0.0: {}
chromatic@13.3.5: {}
chrome-trace-event@1.0.4:
@@ -11598,41 +11356,17 @@ snapshots:
css-mediaquery@0.1.2: {}
css-select@5.2.2:
dependencies:
boolbase: 1.0.0
css-what: 6.2.2
domhandler: 5.0.3
domutils: 3.2.2
nth-check: 2.1.1
css-tree@2.2.1:
dependencies:
mdn-data: 2.0.28
source-map-js: 1.2.1
css-tree@2.3.1:
dependencies:
mdn-data: 2.0.30
source-map-js: 1.2.1
css-tree@3.1.0:
dependencies:
mdn-data: 2.12.2
source-map-js: 1.2.1
css-what@6.2.2: {}
css.escape@1.5.1: {}
cssesc@3.0.0: {}
cssfontparser@1.2.1: {}
csso@5.0.5:
dependencies:
css-tree: 2.2.1
cssstyle@5.3.7:
dependencies:
'@asamuzakjp/css-color': 4.1.1
@@ -11900,18 +11634,6 @@ snapshots:
dom-accessibility-api@0.6.3: {}
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
entities: 4.5.0
domelementtype@2.3.0: {}
domhandler@5.0.3:
dependencies:
domelementtype: 2.3.0
dompurify@3.2.7:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -11920,12 +11642,6 @@ snapshots:
optionalDependencies:
'@types/trusted-types': 2.0.7
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
dotenv@16.6.1: {}
duplexer@0.1.2: {}
@@ -11968,22 +11684,16 @@ snapshots:
empathic@2.0.0: {}
encoding-sniffer@0.2.1:
dependencies:
iconv-lite: 0.6.3
whatwg-encoding: 3.1.1
end-of-stream@1.4.5:
dependencies:
once: 1.4.0
optional: true
enhanced-resolve@5.18.4:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.0
entities@4.5.0: {}
entities@6.0.1: {}
entities@7.0.1: {}
@@ -12085,7 +11795,7 @@ snapshots:
dependencies:
eslint: 9.39.2(jiti@1.21.7)
eslint-plugin-better-tailwindcss@https://pkg.pr.new/hyoban/eslint-plugin-better-tailwindcss@c0161c7(eslint@9.39.2(jiti@1.21.7))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))(typescript@5.9.3):
eslint-plugin-better-tailwindcss@4.1.1(eslint@9.39.2(jiti@1.21.7))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))(typescript@5.9.3):
dependencies:
'@eslint/css-tree': 3.6.8
'@valibot/to-json-schema': 1.5.0(valibot@1.2.0(typescript@5.9.3))
@@ -12113,10 +11823,6 @@ snapshots:
eslint: 9.39.2(jiti@1.21.7)
eslint-compat-utils: 0.5.1(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-hyoban@0.10.1(eslint@9.39.2(jiti@1.21.7)):
dependencies:
eslint: 9.39.2(jiti@1.21.7)
eslint-plugin-import-lite@0.5.0(eslint@9.39.2(jiti@1.21.7)):
dependencies:
eslint: 9.39.2(jiti@1.21.7)
@@ -12611,16 +12317,6 @@ snapshots:
extend@3.0.2: {}
extract-zip@2.0.1:
dependencies:
debug: 4.4.3
get-stream: 5.2.0
yauzl: 2.10.0
optionalDependencies:
'@types/yauzl': 2.10.3
transitivePeerDependencies:
- supports-color
fast-content-type-parse@2.0.1: {}
fast-deep-equal@3.1.3: {}
@@ -12664,10 +12360,6 @@ snapshots:
dependencies:
walk-up-path: 4.0.0
fd-slicer@1.1.0:
dependencies:
pend: 1.2.0
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@@ -12736,10 +12428,6 @@ snapshots:
get-nonce@1.0.1: {}
get-stream@5.2.0:
dependencies:
pump: 3.0.3
get-stream@8.0.1: {}
get-tsconfig@4.13.0:
@@ -12975,13 +12663,6 @@ snapshots:
html-void-elements@3.0.0: {}
htmlparser2@10.1.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
entities: 7.0.1
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
@@ -13010,14 +12691,6 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
iconify-import-svg@0.1.1:
dependencies:
'@iconify/tools': 4.2.0
'@iconify/types': 2.0.0
'@iconify/utils': 3.1.0
transitivePeerDependencies:
- supports-color
iconv-lite@0.6.3:
dependencies:
safer-buffer: '@nolyfill/safer-buffer@1.0.44'
@@ -13642,10 +13315,6 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
mdn-data@2.0.28: {}
mdn-data@2.0.30: {}
mdn-data@2.12.2: {}
mdn-data@2.23.0: {}
@@ -14000,10 +13669,6 @@ snapshots:
minipass@7.1.2: {}
minizlib@3.1.0:
dependencies:
minipass: 7.1.2
mitt@3.0.1: {}
mkdirp-classic@0.5.3:
@@ -14131,6 +13796,7 @@ snapshots:
once@1.4.0:
dependencies:
wrappy: 1.0.2
optional: true
onetime@6.0.0:
dependencies:
@@ -14230,15 +13896,6 @@ snapshots:
parse-statements@1.0.11: {}
parse5-htmlparser2-tree-adapter@7.1.0:
dependencies:
domhandler: 5.0.3
parse5: 7.3.0
parse5-parser-stream@7.1.2:
dependencies:
parse5: 7.3.0
parse5@7.3.0:
dependencies:
entities: 6.0.1
@@ -14281,8 +13938,6 @@ snapshots:
canvas: 3.2.1
path2d: 0.2.2
pend@1.2.0: {}
picocolors@1.1.1: {}
picomatch@2.3.1: {}
@@ -14448,6 +14103,7 @@ snapshots:
dependencies:
end-of-stream: 1.4.5
once: 1.4.0
optional: true
punycode@2.3.1: {}
@@ -15266,16 +14922,6 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
svgo@3.3.2:
dependencies:
'@trysound/sax': 0.2.0
commander: 7.2.0
css-select: 5.2.2
css-tree: 2.3.1
css-what: 6.2.2
csso: 5.0.5
picocolors: 1.1.1
symbol-tree@3.2.4: {}
synckit@0.11.12:
@@ -15337,14 +14983,6 @@ snapshots:
readable-stream: 3.6.2
optional: true
tar@7.5.7:
dependencies:
'@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0
minipass: 7.1.2
minizlib: 3.1.0
yallist: 5.0.0
terser-webpack-plugin@5.3.16(esbuild@0.27.2)(uglify-js@3.19.3)(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
@@ -15500,8 +15138,6 @@ snapshots:
undici-types@6.21.0: {}
undici@7.20.0: {}
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3
@@ -15903,7 +15539,8 @@ snapshots:
string-width: 4.2.3
strip-ansi: 7.1.2
wrappy@1.0.2: {}
wrappy@1.0.2:
optional: true
ws@7.5.10: {}
@@ -15923,8 +15560,6 @@ snapshots:
yallist@3.1.1: {}
yallist@5.0.0: {}
yaml-eslint-parser@2.0.0:
dependencies:
eslint-visitor-keys: 5.0.0
@@ -15932,11 +15567,6 @@ snapshots:
yaml@2.8.2: {}
yauzl@2.10.0:
dependencies:
buffer-crc32: 0.2.13
fd-slicer: 1.1.0
yjs@13.6.29:
dependencies:
lib0: 0.2.117

View File

@@ -1,18 +1,8 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { getIconCollections, iconsPlugin } from '@egoist/tailwindcss-icons'
import tailwindTypography from '@tailwindcss/typography'
import { importSvgCollections } from 'iconify-import-svg'
// @ts-expect-error workaround for turbopack issue
import tailwindThemeVarDefine from './themes/tailwind-theme-var-define.ts'
import typography from './typography.js'
const _dirname = typeof __dirname !== 'undefined'
? __dirname
: path.dirname(fileURLToPath(import.meta.url))
const isProduction = process.env.NODE_ENV === 'production'
const config = {
theme: {
typography,
@@ -156,31 +146,7 @@ const config = {
},
},
},
plugins: [
tailwindTypography,
iconsPlugin({
collections: {
...getIconCollections(['heroicons', 'ri']),
...importSvgCollections({
source: path.resolve(_dirname, 'app/components/base/icons/assets/public'),
prefix: 'custom-public',
ignoreImportErrors: true,
runSVGO: isProduction,
}),
...importSvgCollections({
source: path.resolve(_dirname, 'app/components/base/icons/assets/vender'),
prefix: 'custom-vender',
ignoreImportErrors: true,
runSVGO: isProduction,
}),
},
extraProperties: {
width: '1rem',
height: '1rem',
display: 'block',
},
}),
],
plugins: [tailwindTypography],
// https://github.com/tailwindlabs/tailwindcss/discussions/5969
corePlugins: {
preflight: false,