diff --git a/web/app/components/share/text-generation/components/header-section.spec.tsx b/web/app/components/share/text-generation/components/header-section.spec.tsx new file mode 100644 index 0000000000..8354c7e704 --- /dev/null +++ b/web/app/components/share/text-generation/components/header-section.spec.tsx @@ -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 }) => ( +
{data.title}
+ ), +})) + +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() + + // 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() + + expect(screen.queryByText('A description')).not.toBeInTheDocument() + }) + }) + + // Tab rendering + describe('Tabs', () => { + it('should render create and batch tabs', () => { + render() + + 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() + + expect(screen.getByText(/share\.generation\.tabs\.saved/)).toBeInTheDocument() + }) + + it('should hide saved tab when isWorkflow is true', () => { + render() + + 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() + + expect(screen.getByText('2')).toBeInTheDocument() + }) + }) + + // Menu dropdown + describe('MenuDropdown', () => { + it('should pass hideLogout=true when accessMode is PUBLIC', () => { + render() + + expect(screen.getByTestId('menu-dropdown')).toHaveAttribute('data-hide-logout', 'true') + }) + + it('should pass hideLogout=true when isInstalledApp', () => { + render() + + expect(screen.getByTestId('menu-dropdown')).toHaveAttribute('data-hide-logout', 'true') + }) + + it('should pass hideLogout=false when not installed and accessMode is not PUBLIC', () => { + render() + + 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() + + fireEvent.click(screen.getByText(/share\.generation\.tabs\.batch/)) + + expect(onTabChange).toHaveBeenCalledWith('batch') + }) + }) +}) diff --git a/web/app/components/share/text-generation/components/header-section.tsx b/web/app/components/share/text-generation/components/header-section.tsx new file mode 100644 index 0000000000..7478284a00 --- /dev/null +++ b/web/app/components/share/text-generation/components/header-section.tsx @@ -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 = ({ + 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: , + extra: savedMessages.length > 0 + ? {savedMessages.length} + : null, + }] + : []), + ] + + return ( +
+
+ +
{siteInfo.title}
+ +
+ {siteInfo.description && ( +
{siteInfo.description}
+ )} + +
+ ) +} + +export default HeaderSection diff --git a/web/app/components/share/text-generation/components/powered-by.spec.tsx b/web/app/components/share/text-generation/components/powered-by.spec.tsx new file mode 100644 index 0000000000..ed03175077 --- /dev/null +++ b/web/app/components/share/text-generation/components/powered-by.spec.tsx @@ -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) => { + 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() + + 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() + + const img = screen.getByAltText('logo') + expect(img).toHaveAttribute('src', 'https://example.com/logo.png') + }) + + it('should render custom logo from customConfig', () => { + render( + , + ) + + 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( + , + ) + + 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( + , + ) + + expect(container.innerHTML).toBe('') + }) + + it('should render when remove_webapp_brand is falsy', () => { + const { container } = render( + , + ) + + expect(container.innerHTML).not.toBe('') + }) + }) +}) diff --git a/web/app/components/share/text-generation/components/powered-by.tsx b/web/app/components/share/text-generation/components/powered-by.tsx new file mode 100644 index 0000000000..c8563062f0 --- /dev/null +++ b/web/app/components/share/text-generation/components/powered-by.tsx @@ -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 | null +} + +const PoweredBy: FC = ({ 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 ( +
+
{t('chat.poweredBy', { ns: 'share' })}
+ {logoSrc + ? logo + : } +
+ ) +} + +export default PoweredBy diff --git a/web/app/components/share/text-generation/components/result-panel.spec.tsx b/web/app/components/share/text-generation/components/result-panel.spec.tsx new file mode 100644 index 0000000000..61a9e09730 --- /dev/null +++ b/web/app/components/share/text-generation/components/result-panel.spec.tsx @@ -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[] }) => ( + + ), +})) + +const defaultProps = { + isPC: true, + isShowResultPanel: false, + isCallBatchAPI: false, + totalTasks: 0, + successCount: 0, + failedCount: 0, + noPendingTask: true, + exportRes: [] as Record[], + onRetryFailed: vi.fn(), +} + +describe('ResultPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Renders children + describe('Rendering', () => { + it('should render children content', () => { + render( + +
Result content
+
, + ) + + expect(screen.getByText('Result content')).toBeInTheDocument() + }) + }) + + // Batch header + describe('Batch mode header', () => { + it('should show execution count when isCallBatchAPI is true', () => { + render( + +
+ , + ) + + expect(screen.getByText(/share\.generation\.executions/)).toBeInTheDocument() + }) + + it('should not show execution header when not in batch mode', () => { + render( + +
+ , + ) + + expect(screen.queryByText(/share\.generation\.executions/)).not.toBeInTheDocument() + }) + + it('should show download button when there are successful tasks', () => { + render( + +
+ , + ) + + expect(screen.getByTestId('res-download')).toBeInTheDocument() + }) + + it('should not show download button when no successful tasks', () => { + render( + +
+ , + ) + + 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( + +
+ , + ) + + expect(container.querySelector('.mt-4')).toBeInTheDocument() + }) + + it('should not show loading when all tasks are done', () => { + const { container } = render( + +
+ , + ) + + 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( + +
+ , + ) + + 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( + +
+ , + ) + + fireEvent.click(screen.getByText(/share\.generation\.batchFailed\.retry/)) + + expect(onRetry).toHaveBeenCalledTimes(1) + }) + + it('should not show retry bar when no failed tasks', () => { + render( + +
+ , + ) + + 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( + +
+ , + ) + + expect(screen.queryByText(/share\.generation\.batchFailed\.retry/)).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/share/text-generation/components/result-panel.tsx b/web/app/components/share/text-generation/components/result-panel.tsx new file mode 100644 index 0000000000..1ec17916a1 --- /dev/null +++ b/web/app/components/share/text-generation/components/result-panel.tsx @@ -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[] + onRetryFailed: () => void + children: ReactNode +} + +const ResultPanel: FC = ({ + isPC, + isShowResultPanel, + isCallBatchAPI, + totalTasks, + successCount, + failedCount, + noPendingTask, + exportRes, + onRetryFailed, + children, +}) => { + const { t } = useTranslation() + + return ( +
+ {isCallBatchAPI && ( +
+
+ {t('generation.executions', { ns: 'share', num: totalTasks })} +
+ {successCount > 0 && ( + + )} +
+ )} +
+ {children} + {!noPendingTask && ( +
+ +
+ )} +
+ {isCallBatchAPI && failedCount > 0 && ( +
+ +
+ {t('generation.batchFailed.info', { ns: 'share', num: failedCount })} +
+
+
+ {t('generation.batchFailed.retry', { ns: 'share' })} +
+
+ )} +
+ ) +} + +export default ResultPanel diff --git a/web/app/components/share/text-generation/hooks/use-app-config.spec.ts b/web/app/components/share/text-generation/hooks/use-app-config.spec.ts new file mode 100644 index 0000000000..83e1bd347e --- /dev/null +++ b/web/app/components/share/text-generation/hooks/use-app-config.spec.ts @@ -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() + }) + }) +}) diff --git a/web/app/components/share/text-generation/hooks/use-app-config.ts b/web/app/components/share/text-generation/hooks/use-app-config.ts new file mode 100644 index 0000000000..db4964022f --- /dev/null +++ b/web/app/components/share/text-generation/hooks/use-app-config.ts @@ -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 | 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(() => { + 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(() => { + 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, + } +} diff --git a/web/app/components/share/text-generation/hooks/use-batch-tasks.spec.ts b/web/app/components/share/text-generation/hooks/use-batch-tasks.spec.ts new file mode 100644 index 0000000000..c10751130e --- /dev/null +++ b/web/app/components/share/text-generation/hooks/use-batch-tasks.spec.ts @@ -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 => ({ + 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) + }) + }) +}) diff --git a/web/app/components/share/text-generation/hooks/use-batch-tasks.ts b/web/app/components/share/text-generation/hooks/use-batch-tasks.ts new file mode 100644 index 0000000000..3b44fa0f89 --- /dev/null +++ b/web/app/components/share/text-generation/hooks/use-batch-tasks.ts @@ -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([]) + const allTaskListRef = useRef([]) + 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>({}) + 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 = {} + 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 = {} + 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, + } +} diff --git a/web/app/components/share/text-generation/hooks/use-saved-messages.spec.ts b/web/app/components/share/text-generation/hooks/use-saved-messages.spec.ts new file mode 100644 index 0000000000..9a07831e33 --- /dev/null +++ b/web/app/components/share/text-generation/hooks/use-saved-messages.spec.ts @@ -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>() + 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 +const mockSaveMessage = shareModule.saveMessage as ReturnType +const mockRemoveMessage = shareModule.removeMessage as ReturnType + +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() + }) +}) diff --git a/web/app/components/share/text-generation/hooks/use-saved-messages.ts b/web/app/components/share/text-generation/hooks/use-saved-messages.ts new file mode 100644 index 0000000000..2ec131c6d9 --- /dev/null +++ b/web/app/components/share/text-generation/hooks/use-saved-messages.ts @@ -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({ + 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() + }, + }) +} diff --git a/web/app/components/share/text-generation/index.tsx b/web/app/components/share/text-generation/index.tsx index 90a2fb9277..5b8f3f19f8 100644 --- a/web/app/components/share/text-generation/index.tsx +++ b/web/app/components/share/text-generation/index.tsx @@ -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 -} - -type Task = { - id: number - status: TaskStatus - params: TaskParam -} +import { TaskStatus } from './types' export type IMainProps = { isInstalledApp?: boolean @@ -71,9 +35,6 @@ const TextGeneration: FC = ({ 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 = ({ const mode = searchParams.get('mode') || 'create' const [currentTab, setCurrentTab] = useState(['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>({}) + // 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>({}) const inputsRef = useRef(inputs) - const setInputs = useCallback((newInputs: Record) => { + const setInputs = useCallback((newInputs: Record) => { doSetInputs(newInputs) inputsRef.current = newInputs }, []) - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) - const [appId, setAppId] = useState('') - const [siteInfo, setSiteInfo] = useState(null) - const [customConfig, setCustomConfig] = useState | null>(null) - const [promptConfig, setPromptConfig] = useState(null) - const [moreLikeThisConfig, setMoreLikeThisConfig] = useState(null) - const [textToSpeechConfig, setTextToSpeechConfig] = useState(null) - // save message - const [savedMessages, setSavedMessages] = useState([]) - 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({ - enabled: false, - number_limits: 2, - detail: Resolution.low, - transfer_methods: [TransferMethod.local_file], - }) const [completionFiles, setCompletionFiles] = useState([]) const [runControl, setRunControl] = useState<{ onStop: () => Promise | 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([]) - const allTaskListRef = useRef([]) - 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>({}) - const setBatchCompletionRes = (res: Record) => { - batchCompletionResRef.current = res - } - const getBatchCompletionRes = () => batchCompletionResRef.current - const exportRes = allTaskList.map((task) => { - const batchCompletionResLatest = getBatchCompletionRes() - const res: Record = {} - 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 = {} - 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 = ({ 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) => ( = ({ 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 = ({ 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 = ({ /> ) - const renderBatchRes = () => { - return (showTaskList.map(task => renderRes(task))) - } - - const renderResWrap = ( -
- {isCallBatchAPI && ( -
-
{t('generation.executions', { ns: 'share', num: allTaskList.length })}
- {allSuccessTaskList.length > 0 && ( - - )} -
- )} -
- {!isCallBatchAPI ? renderRes() : renderBatchRes()} - {!noPendingTask && ( -
- -
- )} -
- {isCallBatchAPI && allFailedTaskList.length > 0 && ( -
- -
{t('generation.batchFailed.info', { ns: 'share', num: allFailedTaskList.length })}
-
-
{t('generation.batchFailed.retry', { ns: 'share' })}
-
- )} -
- ) - - if (!appId || !siteInfo || !promptConfig) { + if (!isReady) { return (
) } + return (
= ({ isInstalledApp ? 'h-full rounded-2xl shadow-md' : 'h-screen', )} > - {/* Left */} + {/* Left panel */}
- {/* header */} -
-
- -
{siteInfo.title}
- -
- {siteInfo.description && ( -
{siteInfo.description}
- )} - , - extra: savedMessages.length > 0 - ? ( - - {savedMessages.length} - - ) - : null, - }] - : []), - ]} - value={currentTab} - onChange={setCurrentTab} - /> -
- {/* form */} + + {/* Form content */}
= ({ >
-
+
@@ -598,31 +221,15 @@ const TextGeneration: FC = ({ className={cn(isPC ? 'mt-6' : 'mt-4')} isShowTextToSpeech={textToSpeechConfig?.enabled} list={savedMessages} - onRemove={handleRemoveSavedMessage} + onRemove={id => removeMutation.mutate(id)} onStartCreateContent={() => setCurrentTab('create')} /> )}
- {/* powered by */} - {!customConfig?.remove_webapp_brand && ( -
-
{t('chat.poweredBy', { ns: 'share' })}
- { - systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo - ? logo - : customConfig?.replace_webapp_logo - ? logo - : - } -
- )} +
- {/* Result */} + + {/* Right panel - Results */}
= ({ ? '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()} >
)} - {renderResWrap} + + {!isCallBatchAPI + ? renderRes() + : showTaskList.map(task => renderRes(task))} +
) diff --git a/web/app/components/share/text-generation/run-once/index.tsx b/web/app/components/share/text-generation/run-once/index.tsx index 4531ff8beb..df53af6970 100644 --- a/web/app/components/share/text-generation/run-once/index.tsx +++ b/web/app/components/share/text-generation/run-once/index.tsx @@ -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 = ({ 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 = {} @@ -80,15 +82,16 @@ const RunOnce: FC = ({ runControl?.onStop?.() }, [isRunning, runControl]) - const handleInputsChange = useCallback((newInputs: Record) => { + const handleInputsChange = useCallback((newInputs: Record) => { onInputsChange(newInputs) inputsRef.current = newInputs }, [onInputsChange, inputsRef]) useEffect(() => { - if (isInitialized) + if (isInitializedRef.current) return - const newInputs: Record = {} + isInitializedRef.current = true + const newInputs: Record = {} promptConfig.prompt_variables.forEach((item) => { if (item.type === 'select') newInputs[item.key] = item.default @@ -106,7 +109,6 @@ const RunOnce: FC = ({ newInputs[item.key] = undefined }) onInputsChange(newInputs) - setIsInitialized(true) }, [promptConfig.prompt_variables, onInputsChange]) return ( @@ -114,7 +116,7 @@ const RunOnce: FC = ({
{/* input form */}
- {(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 => (
@@ -169,22 +171,21 @@ const RunOnce: FC = ({ )} {item.type === 'file' && ( { 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' && ( { 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, }} /> )} diff --git a/web/app/components/share/text-generation/types.ts b/web/app/components/share/text-generation/types.ts index 144ced28a2..32c9e17432 100644 --- a/web/app/components/share/text-generation/types.ts +++ b/web/app/components/share/text-generation/types.ts @@ -1,5 +1,9 @@ -type TaskParam = { - inputs: Record +import type { FileEntity } from '@/app/components/base/file-uploader/types' + +export type InputValueTypes = string | boolean | number | string[] | FileEntity | FileEntity[] | Record | undefined + +export type TaskParam = { + inputs: Record } 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 diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index e38c1905db..574173194d 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -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