Browse Source

feat: batch run support retry errors and decrease rate limit times (#1215)

Joel 1 year ago
parent
commit
c40ee7e629
22 changed files with 428 additions and 127 deletions
  1. 106 103
      web/app/components/app/text-generate/item/index.tsx
  2. 3 0
      web/app/components/base/icons/assets/vender/line/arrows/refresh-ccw-01.svg
  3. 3 0
      web/app/components/base/icons/assets/vender/line/files/clipboard.svg
  4. 3 0
      web/app/components/base/icons/assets/vender/line/general/bookmark.svg
  5. 3 0
      web/app/components/base/icons/assets/vender/line/weather/stars-02.svg
  6. 29 0
      web/app/components/base/icons/src/vender/line/arrows/RefreshCcw01.json
  7. 16 0
      web/app/components/base/icons/src/vender/line/arrows/RefreshCcw01.tsx
  8. 1 0
      web/app/components/base/icons/src/vender/line/arrows/index.ts
  9. 29 0
      web/app/components/base/icons/src/vender/line/files/Clipboard.json
  10. 16 0
      web/app/components/base/icons/src/vender/line/files/Clipboard.tsx
  11. 1 0
      web/app/components/base/icons/src/vender/line/files/index.ts
  12. 29 0
      web/app/components/base/icons/src/vender/line/general/Bookmark.json
  13. 16 0
      web/app/components/base/icons/src/vender/line/general/Bookmark.tsx
  14. 1 0
      web/app/components/base/icons/src/vender/line/general/index.ts
  15. 29 0
      web/app/components/base/icons/src/vender/line/weather/Stars02.json
  16. 16 0
      web/app/components/base/icons/src/vender/line/weather/Stars02.tsx
  17. 1 0
      web/app/components/base/icons/src/vender/line/weather/index.ts
  18. 70 18
      web/app/components/share/text-generation/index.tsx
  19. 39 3
      web/app/components/share/text-generation/result/index.tsx
  20. 7 3
      web/app/components/share/text-generation/run-batch/index.tsx
  21. 5 0
      web/i18n/lang/share-app.en.ts
  22. 5 0
      web/i18n/lang/share-app.zh.ts

File diff suppressed because it is too large
+ 106 - 103
web/app/components/app/text-generate/item/index.tsx


+ 3 - 0
web/app/components/base/icons/assets/vender/line/arrows/refresh-ccw-01.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M2 10C2 10 4.00498 7.26822 5.63384 5.63824C7.26269 4.00827 9.5136 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.89691 21 4.43511 18.2543 3.35177 14.5M2 10V4M2 10H8" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

File diff suppressed because it is too large
+ 3 - 0
web/app/components/base/icons/assets/vender/line/files/clipboard.svg


+ 3 - 0
web/app/components/base/icons/assets/vender/line/general/bookmark.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M5 7.8C5 6.11984 5 5.27976 5.32698 4.63803C5.6146 4.07354 6.07354 3.6146 6.63803 3.32698C7.27976 3 8.11984 3 9.8 3H14.2C15.8802 3 16.7202 3 17.362 3.32698C17.9265 3.6146 18.3854 4.07354 18.673 4.63803C19 5.27976 19 6.11984 19 7.8V21L12 17L5 21V7.8Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

File diff suppressed because it is too large
+ 3 - 0
web/app/components/base/icons/assets/vender/line/weather/stars-02.svg


+ 29 - 0
web/app/components/base/icons/src/vender/line/arrows/RefreshCcw01.json

@@ -0,0 +1,29 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "24",
+			"height": "24",
+			"viewBox": "0 0 24 24",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "path",
+				"attributes": {
+					"d": "M2 10C2 10 4.00498 7.26822 5.63384 5.63824C7.26269 4.00827 9.5136 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.89691 21 4.43511 18.2543 3.35177 14.5M2 10V4M2 10H8",
+					"stroke": "currentColor",
+					"stroke-width": "2",
+					"stroke-linecap": "round",
+					"stroke-linejoin": "round"
+				},
+				"children": []
+			}
+		]
+	},
+	"name": "RefreshCcw01"
+}

+ 16 - 0
web/app/components/base/icons/src/vender/line/arrows/RefreshCcw01.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './RefreshCcw01.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'RefreshCcw01'
+
+export default Icon

+ 1 - 0
web/app/components/base/icons/src/vender/line/arrows/index.ts

@@ -3,4 +3,5 @@ export { default as ArrowUpRight } from './ArrowUpRight'
 export { default as ChevronDownDouble } from './ChevronDownDouble'
 export { default as ChevronDown } from './ChevronDown'
 export { default as ChevronRight } from './ChevronRight'
+export { default as RefreshCcw01 } from './RefreshCcw01'
 export { default as RefreshCw05 } from './RefreshCw05'

File diff suppressed because it is too large
+ 29 - 0
web/app/components/base/icons/src/vender/line/files/Clipboard.json


+ 16 - 0
web/app/components/base/icons/src/vender/line/files/Clipboard.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Clipboard.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'Clipboard'
+
+export default Icon

+ 1 - 0
web/app/components/base/icons/src/vender/line/files/index.ts

@@ -1 +1,2 @@
+export { default as Clipboard } from './Clipboard'
 export { default as FilePlus02 } from './FilePlus02'

+ 29 - 0
web/app/components/base/icons/src/vender/line/general/Bookmark.json

@@ -0,0 +1,29 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "24",
+			"height": "24",
+			"viewBox": "0 0 24 24",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "path",
+				"attributes": {
+					"d": "M5 7.8C5 6.11984 5 5.27976 5.32698 4.63803C5.6146 4.07354 6.07354 3.6146 6.63803 3.32698C7.27976 3 8.11984 3 9.8 3H14.2C15.8802 3 16.7202 3 17.362 3.32698C17.9265 3.6146 18.3854 4.07354 18.673 4.63803C19 5.27976 19 6.11984 19 7.8V21L12 17L5 21V7.8Z",
+					"stroke": "currentColor",
+					"stroke-width": "2",
+					"stroke-linecap": "round",
+					"stroke-linejoin": "round"
+				},
+				"children": []
+			}
+		]
+	},
+	"name": "Bookmark"
+}

+ 16 - 0
web/app/components/base/icons/src/vender/line/general/Bookmark.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Bookmark.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'Bookmark'
+
+export default Icon

+ 1 - 0
web/app/components/base/icons/src/vender/line/general/index.ts

@@ -1,4 +1,5 @@
 export { default as AtSign } from './AtSign'
+export { default as Bookmark } from './Bookmark'
 export { default as Check } from './Check'
 export { default as DotsHorizontal } from './DotsHorizontal'
 export { default as Edit03 } from './Edit03'

File diff suppressed because it is too large
+ 29 - 0
web/app/components/base/icons/src/vender/line/weather/Stars02.json


+ 16 - 0
web/app/components/base/icons/src/vender/line/weather/Stars02.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Stars02.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'Stars02'
+
+export default Icon

+ 1 - 0
web/app/components/base/icons/src/vender/line/weather/index.ts

@@ -0,0 +1 @@
+export { default as Stars02 } from './Stars02'

+ 70 - 18
web/app/components/share/text-generation/index.tsx

@@ -3,11 +3,12 @@ import type { FC } from 'react'
 import React, { useEffect, useRef, useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import cn from 'classnames'
-import { useBoolean, useClickAway, useGetState } from 'ahooks'
+import { useBoolean, useClickAway } from 'ahooks'
 import { XMarkIcon } from '@heroicons/react/24/outline'
 import TabHeader from '../../base/tab-header'
 import Button from '../../base/button'
 import { checkOrSetAccessToken } from '../utils'
+import { AlertCircle } from '../../base/icons/src/vender/solid/alertsAndFeedback'
 import s from './style.module.css'
 import RunBatch from './run-batch'
 import ResDownload from './run-batch/res-download'
@@ -25,12 +26,12 @@ import SavedItems from '@/app/components/app/text-generate/saved-items'
 import type { InstalledApp } from '@/models/explore'
 import { DEFAULT_VALUE_MAX_LEN, appDefaultIconBackground } from '@/config'
 import Toast from '@/app/components/base/toast'
-
-const PARALLEL_LIMIT = 5
+const GROUP_SIZE = 5 // 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 = {
@@ -99,15 +100,41 @@ const TextGeneration: FC<IMainProps> = ({
     showResSidebar()
   }
 
-  const [allTaskList, setAllTaskList, getLatestTaskList] = useGetState<Task[]>([])
+  const [controlRetry, setControlRetry] = useState(0)
+  const handleRetryAllFailedTask = () => {
+    setControlRetry(Date.now())
+  }
+  const [allTaskList, doSetAllTaskList] = useState<Task[]>([])
+  const allTaskListRef = useRef<Task[]>([])
+  const getLatestTaskList = () => allTaskListRef.current
+  const setAllTaskList = (taskList: Task[]) => {
+    doSetAllTaskList(taskList)
+    allTaskListRef.current = taskList
+  }
   const pendingTaskList = allTaskList.filter(task => task.status === TaskStatus.pending)
   const noPendingTask = pendingTaskList.length === 0
   const showTaskList = allTaskList.filter(task => task.status !== TaskStatus.pending)
+  const [currGroupNum, doSetCurrGroupNum] = useState(0)
+  const currGroupNumRef = useRef(0)
+  const setCurrGroupNum = (num: number) => {
+    doSetCurrGroupNum(num)
+    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 allTaskFinished = allTaskList.every(task => task.status === TaskStatus.completed)
-  const [batchCompletionRes, setBatchCompletionRes, getBatchCompletionRes] = useGetState<Record<string, string>>({})
+  const allTaskRuned = allTaskList.every(task => [TaskStatus.completed, TaskStatus.failed].includes(task.status))
+  const [batchCompletionRes, doSetBatchCompletionRes] = useState<Record<string, string>>({})
+  const batchCompletionResRef = useRef<Record<string, string>>({})
+  const setBatchCompletionRes = (res: Record<string, string>) => {
+    doSetBatchCompletionRes(res)
+    batchCompletionResRef.current = res
+  }
+  const getBatchCompletionRes = () => batchCompletionResRef.current
   const exportRes = allTaskList.map((task) => {
-    if (allTaskList.length > 0 && !allTaskFinished)
-      return {}
     const batchCompletionResLatest = getBatchCompletionRes()
     const res: Record<string, string> = {}
     const { inputs } = task.params
@@ -123,7 +150,6 @@ const TextGeneration: FC<IMainProps> = ({
       return false
     }
     const headerData = data[0]
-    const varLen = promptConfig?.prompt_variables.length || 0
     let isMapVarName = true
     promptConfig?.prompt_variables.forEach((item, index) => {
       if (!isMapVarName)
@@ -234,7 +260,7 @@ const TextGeneration: FC<IMainProps> = ({
       }
       return {
         id: i + 1,
-        status: i < PARALLEL_LIMIT ? TaskStatus.running : TaskStatus.pending,
+        status: i < GROUP_SIZE ? TaskStatus.running : TaskStatus.pending,
         params: {
           inputs,
         },
@@ -248,20 +274,28 @@ const TextGeneration: FC<IMainProps> = ({
     // eslint-disable-next-line @typescript-eslint/no-use-before-define
     showResSidebar()
   }
-  const handleCompleted = (completionRes: string, taskId?: number) => {
+  const handleCompleted = (completionRes: string, taskId?: number, isSuccess?: boolean) => {
     const allTasklistLatest = getLatestTaskList()
     const batchCompletionResLatest = getBatchCompletionRes()
     const pendingTaskList = allTasklistLatest.filter(task => task.status === TaskStatus.pending)
-    const nextPendingTaskId = pendingTaskList[0]?.id
-    // console.log(`start: ${allTasklistLatest.map(item => item.status).join(',')}`)
+    const hadRunedTaskNum = 1 + allTasklistLatest.filter(task => [TaskStatus.completed, TaskStatus.failed].includes(task.status)).length
+    const needToAddNextGroupTask = (getCurrGroupNum() !== hadRunedTaskNum) && pendingTaskList.length > 0 && (hadRunedTaskNum % GROUP_SIZE === 0 || (allTasklistLatest.length - hadRunedTaskNum < GROUP_SIZE))
+    // avoid add many task at the same time
+    if (needToAddNextGroupTask)
+      setCurrGroupNum(hadRunedTaskNum)
+    // console.group()
+    // console.log(`[#${taskId}]: ${isSuccess ? 'success' : 'fail'}.currGroupNum: ${getCurrGroupNum()}.hadRunedTaskNum: ${hadRunedTaskNum}, needToAddNextGroupTask: ${needToAddNextGroupTask}`)
+    // console.log([...allTasklistLatest.filter(task => [TaskStatus.completed, TaskStatus.failed].includes(task.status)).map(item => item.id), taskId].sort((a: any, b: any) => a - b).join(','))
+    // console.groupEnd()
+    const nextPendingTaskIds = needToAddNextGroupTask ? pendingTaskList.slice(0, GROUP_SIZE).map(item => item.id) : []
     const newAllTaskList = allTasklistLatest.map((item) => {
       if (item.id === taskId) {
         return {
           ...item,
-          status: TaskStatus.completed,
+          status: isSuccess ? TaskStatus.completed : TaskStatus.failed,
         }
       }
-      if (item.id === nextPendingTaskId) {
+      if (needToAddNextGroupTask && nextPendingTaskIds.includes(item.id)) {
         return {
           ...item,
           status: TaskStatus.running,
@@ -269,7 +303,6 @@ const TextGeneration: FC<IMainProps> = ({
       }
       return item
     })
-    // console.log(`end: ${newAllTaskList.map(item => item.status).join(',')}`)
     setAllTaskList(newAllTaskList)
     if (taskId) {
       setBatchCompletionRes({
@@ -333,10 +366,12 @@ const TextGeneration: FC<IMainProps> = ({
     isMobile={isMobile}
     isInstalledApp={!!isInstalledApp}
     installedAppInfo={installedAppInfo}
+    isError={task?.status === TaskStatus.failed}
     promptConfig={promptConfig}
     moreLikeThisEnabled={!!moreLikeThisConfig?.enabled}
     inputs={isCallBatchAPI ? (task as Task).params.inputs : inputs}
     controlSend={controlSend}
+    controlRetry={task?.status === TaskStatus.failed ? controlRetry : 0}
     controlStopResponding={controlStopResponding}
     onShowRes={showResSidebar}
     handleSaveMessage={handleSaveMessage}
@@ -365,7 +400,19 @@ const TextGeneration: FC<IMainProps> = ({
             <div className='text-lg text-gray-800 font-semibold'>{t('share.generation.title')}</div>
           </div>
           <div className='flex items-center space-x-2'>
-            {allTaskList.length > 0 && allTaskFinished && (
+            {allFailedTaskList.length > 0 && (
+              <div className='flex items-center'>
+                <AlertCircle className='w-4 h-4 text-[#D92D20]' />
+                <div className='ml-1 text-[#D92D20]'>{t('share.generation.batchFailed.info', { num: allFailedTaskList.length })}</div>
+                <Button
+                  type='primary'
+                  className='ml-2 !h-8 !px-3'
+                  onClick={handleRetryAllFailedTask}
+                >{t('share.generation.batchFailed.retry')}</Button>
+                <div className='mx-3 w-[1px] h-3.5 bg-gray-200'></div>
+              </div>
+            )}
+            {allSuccessTaskList.length > 0 && (
               <ResDownload
                 isMobile={isMobile}
                 values={exportRes}
@@ -394,8 +441,12 @@ const TextGeneration: FC<IMainProps> = ({
     </div>
   )
 
-  if (!appId || !siteInfo || !promptConfig)
-    return <Loading type='app' />
+  if (!appId || !siteInfo || !promptConfig) {
+    return (
+      <div className='flex items-center h-screen'>
+        <Loading type='app' />
+      </div>)
+  }
 
   return (
     <>
@@ -466,6 +517,7 @@ const TextGeneration: FC<IMainProps> = ({
               <RunBatch
                 vars={promptConfig.prompt_variables}
                 onSend={handleRunBatch}
+                isAllFinished={allTaskRuned}
               />
             </div>
 

+ 39 - 3
web/app/components/share/text-generation/result/index.tsx

@@ -1,7 +1,7 @@
 'use client'
 import type { FC } from 'react'
-import React, { useEffect, useState } from 'react'
-import { useBoolean, useGetState } from 'ahooks'
+import React, { useEffect, useRef, useState } from 'react'
+import { useBoolean } from 'ahooks'
 import { t } from 'i18next'
 import cn from 'classnames'
 import TextGenerationRes from '@/app/components/app/text-generate/item'
@@ -18,10 +18,12 @@ export type IResultProps = {
   isMobile: boolean
   isInstalledApp: boolean
   installedAppInfo?: InstalledApp
+  isError: boolean
   promptConfig: PromptConfig | null
   moreLikeThisEnabled: boolean
   inputs: Record<string, any>
   controlSend?: number
+  controlRetry?: number
   controlStopResponding?: number
   onShowRes: () => void
   handleSaveMessage: (messageId: string) => void
@@ -35,10 +37,12 @@ const Result: FC<IResultProps> = ({
   isMobile,
   isInstalledApp,
   installedAppInfo,
+  isError,
   promptConfig,
   moreLikeThisEnabled,
   inputs,
   controlSend,
+  controlRetry,
   controlStopResponding,
   onShowRes,
   handleSaveMessage,
@@ -51,7 +55,13 @@ const Result: FC<IResultProps> = ({
       setResponsingFalse()
   }, [controlStopResponding])
 
-  const [completionRes, setCompletionRes, getCompletionRes] = useGetState('')
+  const [completionRes, doSetCompletionRes] = useState('')
+  const completionResRef = useRef('')
+  const setCompletionRes = (res: string) => {
+    completionResRef.current = res
+    doSetCompletionRes(res)
+  }
+  const getCompletionRes = () => completionResRef.current
   const { notify } = Toast
   const isNoData = !completionRes
 
@@ -124,6 +134,17 @@ const Result: FC<IResultProps> = ({
       onShowRes()
 
     setResponsingTrue()
+    const startTime = Date.now()
+    let isTimeout = false
+    const runId = setInterval(() => {
+      if (Date.now() - startTime > 1000 * 60) { // 1min timeout
+        clearInterval(runId)
+        setResponsingFalse()
+        onCompleted(getCompletionRes(), taskId, false)
+        isTimeout = true
+        console.log(`[#${taskId}]: timeout`)
+      }
+    }, 1000)
     sendCompletionMessage(data, {
       onData: (data: string, _isFirstMessage: boolean, { messageId }) => {
         tempMessageId = messageId
@@ -131,13 +152,21 @@ const Result: FC<IResultProps> = ({
         setCompletionRes(res.join(''))
       },
       onCompleted: () => {
+        if (isTimeout)
+          return
+
         setResponsingFalse()
         setMessageId(tempMessageId)
         onCompleted(getCompletionRes(), taskId, true)
+        clearInterval(runId)
       },
       onError() {
+        if (isTimeout)
+          return
+
         setResponsingFalse()
         onCompleted(getCompletionRes(), taskId, false)
+        clearInterval(runId)
       },
     }, isInstalledApp, installedAppInfo?.id)
   }
@@ -150,9 +179,16 @@ const Result: FC<IResultProps> = ({
     }
   }, [controlSend])
 
+  useEffect(() => {
+    if (controlRetry)
+      handleSend()
+  }, [controlRetry])
+
   const renderTextGenerationRes = () => (
     <TextGenerationRes
       className='mt-3'
+      isError={isError}
+      onRetry={handleSend}
       content={completionRes}
       messageId={messageId}
       isInWebApp

+ 7 - 3
web/app/components/share/text-generation/run-batch/index.tsx

@@ -5,18 +5,21 @@ import {
   PlayIcon,
 } from '@heroicons/react/24/solid'
 import { useTranslation } from 'react-i18next'
+import cn from 'classnames'
 import CSVReader from './csv-reader'
 import CSVDownload from './csv-download'
 import Button from '@/app/components/base/button'
-
+import { Loading02 } from '@/app/components/base/icons/src/vender/line/general'
 export type IRunBatchProps = {
   vars: { name: string }[]
   onSend: (data: string[][]) => void
+  isAllFinished: boolean
 }
 
 const RunBatch: FC<IRunBatchProps> = ({
   vars,
   onSend,
+  isAllFinished,
 }) => {
   const { t } = useTranslation()
 
@@ -31,6 +34,7 @@ const RunBatch: FC<IRunBatchProps> = ({
   const handleSend = () => {
     onSend(csvData)
   }
+  const Icon = isAllFinished ? PlayIcon : Loading02
   return (
     <div className='pt-4'>
       <CSVReader onParsed={handleParsed} />
@@ -41,9 +45,9 @@ const RunBatch: FC<IRunBatchProps> = ({
           type="primary"
           className='mt-4 !h-8 !pl-3 !pr-4'
           onClick={handleSend}
-          disabled={!isParsed}
+          disabled={!isParsed || !isAllFinished}
         >
-          <PlayIcon className="shrink-0 w-4 h-4 mr-1" aria-hidden="true" />
+          <Icon className={cn(!isAllFinished && 'animate-spin', 'shrink-0 w-4 h-4 mr-1')} aria-hidden="true" />
           <span className='uppercase text-[13px]'>{t('share.generation.run')}</span>
         </Button>
       </div>

+ 5 - 0
web/i18n/lang/share-app.en.ts

@@ -54,6 +54,11 @@ const translation = {
     csvStructureTitle: 'The CSV file must conform to the following structure:',
     downloadTemplate: 'Download the template here',
     field: 'Field',
+    batchFailed: {
+      info: '{{num}} failed executions',
+      retry: 'Retry',
+      outputPlaceholder: 'No output content',
+    },
     errorMsg: {
       empty: 'Please input content in the uploaded file.',
       fileStructNotMatch: 'The uploaded CSV file not match the struct.',

+ 5 - 0
web/i18n/lang/share-app.zh.ts

@@ -51,6 +51,11 @@ const translation = {
     csvStructureTitle: 'CSV 文件必须符合以下结构:',
     downloadTemplate: '下载模板',
     field: '',
+    batchFailed: {
+      info: '{{num}} 次运行失败',
+      retry: '重试',
+      outputPlaceholder: '无输出内容',
+    },
     errorMsg: {
       empty: '上传文件的内容不能为空',
       fileStructNotMatch: '上传文件的内容与结构不匹配',