'use client' import type { FC } from 'react' import type { MoreLikeThisConfig, PromptConfig, SavedMessage, TextToSpeechConfig, } from '@/models/debug' 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 { useBoolean } from 'ahooks' import { useSearchParams } from 'next/navigation' import * as React from 'react' import { useCallback, useEffect, 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 { cn } from '@/utils/classnames' import { userInputsFormToPromptVariables } from '@/utils/model-config' import TabHeader from '../../base/tab-header' import MenuDropdown from './menu-dropdown' import RunBatch from './run-batch' import ResDownload from './run-batch/res-download' import BatchProgress from './run-batch/batch-progress' // Extend: Batch import import Pagination from '@/app/components/base/pagination' // Extend: Batch import // Extend: Start Batch import import { downloadBatchApi, fetchBatchWorkflowListApi, processExcelUploadApi } from '@/service/web-extend' // Extend: Stop Batch import 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 } export type IMainProps = { isInstalledApp?: boolean installedAppInfo?: InstalledApp isWorkflow?: boolean } const TextGeneration: FC = ({ isInstalledApp = false, installedAppInfo, isWorkflow = false, }) => { const { notify } = Toast const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp const { t } = useTranslation() const media = useBreakpoints() const isPC = media === MediaType.pc const searchParams = useSearchParams() 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>({}) const inputsRef = useRef(inputs) 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 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]) const handleSend = () => { setIsCallBatchAPI(false) 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() } 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 } // Extend: Start Batch import - 批量处理相关状态 const [batchJobs, setBatchJobs] = useState>([]) // 分页状态 const [currentPage, setCurrentPage] = useState(1) const batchJobsLimit = 5 // 每页5个任务 const [totalBatchJobs, setTotalBatchJobs] = useState(0) const [isLoadingBatchJobs, setIsLoadingBatchJobs] = useState(false) const lastRefreshTimeRef = useRef(0) // 记录上次刷新时间,避免频繁刷新 // 从后端获取批量工作流列表 const loadBatchWorkflows = useCallback(async (force = false) => { if (!appId || currentTab !== 'batch') return // 防止过于频繁的刷新(至少间隔 1 秒) const now = Date.now() if (!force && now - lastRefreshTimeRef.current < 1000) return lastRefreshTimeRef.current = now setIsLoadingBatchJobs(true) try { const result = await fetchBatchWorkflowListApi(installedAppInfo?.id, currentPage, batchJobsLimit) if (result) { // 转换数据格式以兼容现有组件 const convertedJobs = result.items.map(item => ({ id: item.id, error: item.error, error_count: item.error_count, fileName: item.file_name, createdAt: item.created_at, status: item.status, totalRows: item.total_rows, processedRows: item.processed_rows, })) setBatchJobs(convertedJobs) setTotalBatchJobs(result.total) } } catch (error) { console.error('Failed to load batch workflows:', error) } finally { setIsLoadingBatchJobs(false) } }, [appId, currentTab, currentPage, installedAppInfo?.id, batchJobsLimit]) // 加载批量工作流列表 useEffect(() => { loadBatchWorkflows() }, [loadBatchWorkflows]) // 注意:不再需要自动刷新逻辑,因为每个批量任务现在自己管理进度刷新 // 每个 BatchProgress 组件会独立轮询自己的进度(每 3 秒) // 计算分页数据 - 现在数据已经是从后端分页获取的,不需要再切片 const paginatedBatchJobs = batchJobs // Extend: Stop Batch import 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)) 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) setControlSend(Date.now()) // clear run once task status setControlStopResponding(Date.now()) // eslint-disable-next-line ts/no-use-before-define showResultPanel() } // Extend: Start Batch import - 处理批量上传 const handleBatchUpload = async (originalFile: File, data: string[][], originalFileName?: string) => { if (!checkBatchInputs(data)) return try { // 创建key-name映射 const keyNameMapping: Record = {} promptConfig?.prompt_variables.forEach((variable) => { keyNameMapping[variable.name] = variable.key }) // 直接使用原始文件 const result = await processExcelUploadApi(originalFile, installedAppInfo?.id || '', appId, keyNameMapping) if (result === null) { // API调用失败,错误信息已经在processExcelUploadApi中显示 return } // 上传成功后,重新加载批量任务列表 await loadBatchWorkflows() // 显示结果面板 // eslint-disable-next-line ts/no-use-before-define showResultPanel() notify({ type: 'success', message: t('batchWorkflow.batchUploadSuccess', { ns: 'extend' }) }) } catch (error) { console.error('批量上传失败:', error) notify({ type: 'error', message: t('batchWorkflow.batchUploadFailed', { ns: 'extend' }) }) } } // 下载批量处理结果 const handleBatchDownload = async (batchId: string) => { try { const blob = await downloadBatchApi(batchId) if (blob) { const url = window.URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `batch_results_${batchId}.csv` document.body.appendChild(a) a.click() window.URL.revokeObjectURL(url) document.body.removeChild(a) } } catch (error) { console.error('下载失败:', error) notify({ type: 'error', message: t('batchWorkflow.downloadFailed', { ns: 'extend' }) }) } } // 处理重试成功回调 const handleRetrySuccess = () => { // 重试成功后,重新加载批量工作流列表 loadBatchWorkflows() console.log('批量任务重试成功,已刷新列表') } // 处理单个任务进度更新(只更新列表中的对应项,不刷新整个列表) const handleJobUpdate = useCallback((jobId: string, updatedData: { status: string, processedRows: number, error?: string }) => { setBatchJobs(prevJobs => { // 检查是否真的有变化 const job = prevJobs.find(j => j.id === jobId) if (!job) return prevJobs // 如果没有变化,不更新 if (job.status === updatedData.status && job.processedRows === updatedData.processedRows && job.error === updatedData.error) return prevJobs // 有变化才更新 return prevJobs.map(job => job.id === jobId ? { ...job, ...updatedData } : job ) }) }, []) // Extend: Stop Batch import 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) 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, icon: siteInfo?.icon, icon_background: siteInfo?.icon_background, 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) => ( setResultExisted(true)} onRunControlChange={!isCallBatchAPI ? setRunControl : undefined} hideInlineStopButton={!isCallBatchAPI} /> ) const renderBatchRes = () => { return (showTaskList.map(task => renderRes(task))) } const renderResWrap = (
{/* Extend: Start Batch import */} {(isCallBatchAPI || (isInBatchTab && batchJobs.length > 0)) && (
{isCallBatchAPI ? t('generation.executions', { ns: 'share', num: allTaskList.length }) : t('batchWorkflow.batchJobs', { ns: 'extend', num: batchJobs.length })}
{allSuccessTaskList.length > 0 && ( )}
)}
0)) && 'pt-0', !isPC && 'p-0 pb-2', )} > {!isCallBatchAPI && !(isInBatchTab && batchJobs.length > 0) ? renderRes() : ( <> {isCallBatchAPI && renderBatchRes()} {isInBatchTab && batchJobs.length > 0 && (
{/* 数据保留提示 */}
{t('batchWorkflow.dataRetentionNotice', { ns: 'extend' })}: {t('batchWorkflow.dataRetentionDescription', { ns: 'extend' })}
{/* 批量任务列表 */}
{isLoadingBatchJobs ? (
) : paginatedBatchJobs.length > 0 ? ( paginatedBatchJobs.map(job => ( handleBatchDownload(job.id)} onRetrySuccess={handleRetrySuccess} onJobUpdate={(updatedData) => handleJobUpdate(job.id, updatedData)} /> )) ) : (
{t('batchWorkflow.noBatchTasks', { ns: 'extend' })}
)}
{/* 分页控件 */} {totalBatchJobs > batchJobsLimit && (
{ setCurrentPage(page) // 页面变化时会自动触发useEffect重新加载数据 }} total={totalBatchJobs} limit={batchJobsLimit} className="w-auto" />
)}
)} )} {!noPendingTask && isCallBatchAPI && (
)}
{/* Extend: Stop Batch import */} {isCallBatchAPI && allFailedTaskList.length > 0 && (
{t('generation.batchFailed.info', { ns: 'share', num: allFailedTaskList.length })}
{t('generation.batchFailed.retry', { ns: 'share' })}
)}
) if (!appId || !siteInfo || !promptConfig) { return (
) } return (
{/* Left */}
{/* header */}
{siteInfo.title}
{siteInfo.description && (
{siteInfo.description}
)} , extra: savedMessages.length > 0 ? ( {savedMessages.length} ) : null, }] : []), ]} value={currentTab} onChange={setCurrentTab} />
{/* form */}
{currentTab === 'saved' && ( 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 */}
{!isPC && (
{ if (isShowResultPanel) hideResultPanel() else showResultPanel() }} >
)} {renderResWrap}
) } export default TextGeneration