mirror of
https://github.com/langgenius/dify.git
synced 2026-03-26 00:38:03 +08:00
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.
This commit is contained in:
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -3182,14 +3174,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
|
||||
|
||||
Reference in New Issue
Block a user