Browse Source

feat: text generation application support run batch (#529)

Joel 1 year ago
parent
commit
11baff6740
24 changed files with 1009 additions and 172 deletions
  1. 21 1
      web/app/components/app/text-generate/item/index.tsx
  2. 24 0
      web/app/components/base/icons/assets/public/files/csv.svg
  3. 3 0
      web/app/components/base/icons/assets/vender/solid/general/download-02.svg
  4. 181 0
      web/app/components/base/icons/src/public/files/Csv.json
  5. 14 0
      web/app/components/base/icons/src/public/files/Csv.tsx
  6. 1 0
      web/app/components/base/icons/src/public/files/index.ts
  7. 29 0
      web/app/components/base/icons/src/vender/solid/general/Download02.json
  8. 14 0
      web/app/components/base/icons/src/vender/solid/general/Download02.tsx
  9. 1 0
      web/app/components/base/icons/src/vender/solid/general/index.ts
  10. 29 19
      web/app/components/base/tab-header/index.tsx
  11. 221 120
      web/app/components/share/text-generation/index.tsx
  12. 34 0
      web/app/components/share/text-generation/result/content.tsx
  13. 195 24
      web/app/components/share/text-generation/result/index.tsx
  14. 70 0
      web/app/components/share/text-generation/run-batch/csv-download/index.tsx
  15. 70 0
      web/app/components/share/text-generation/run-batch/csv-reader/index.tsx
  16. 11 0
      web/app/components/share/text-generation/run-batch/csv-reader/style.module.css
  17. 53 0
      web/app/components/share/text-generation/run-batch/index.tsx
  18. 4 4
      web/app/components/share/text-generation/config-scence/index.tsx
  19. 2 0
      web/i18n/lang/app-debug.en.ts
  20. 1 0
      web/i18n/lang/app-debug.zh.ts
  21. 15 2
      web/i18n/lang/share-app.en.ts
  22. 14 1
      web/i18n/lang/share-app.zh.ts
  23. 1 0
      web/package.json
  24. 1 1
      web/service/base.ts

+ 21 - 1
web/app/components/app/text-generate/item/index.tsx

@@ -1,11 +1,12 @@
 'use client'
 'use client'
 import type { FC } from 'react'
 import type { FC } from 'react'
-import React, { useState } from 'react'
+import React, { useEffect, useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
 import cn from 'classnames'
 import cn from 'classnames'
 import copy from 'copy-to-clipboard'
 import copy from 'copy-to-clipboard'
 import { HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline'
 import { HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline'
 import { useBoolean } from 'ahooks'
 import { useBoolean } from 'ahooks'
+import { HashtagIcon } from '@heroicons/react/24/solid'
 import { Markdown } from '@/app/components/base/markdown'
 import { Markdown } from '@/app/components/base/markdown'
 import Loading from '@/app/components/base/loading'
 import Loading from '@/app/components/base/loading'
 import Toast from '@/app/components/base/toast'
 import Toast from '@/app/components/base/toast'
@@ -27,6 +28,8 @@ export type IGenerationItemProps = {
   isMobile?: boolean
   isMobile?: boolean
   isInstalledApp: boolean
   isInstalledApp: boolean
   installedAppId?: string
   installedAppId?: string
+  taskId?: string
+  controlClearMoreLikeThis?: number
 }
 }
 
 
 export const SimpleBtn = ({ className, onClick, children }: {
 export const SimpleBtn = ({ className, onClick, children }: {
@@ -81,6 +84,8 @@ const GenerationItem: FC<IGenerationItemProps> = ({
   isMobile,
   isMobile,
   isInstalledApp,
   isInstalledApp,
   installedAppId,
   installedAppId,
+  taskId,
+  controlClearMoreLikeThis,
 }) => {
 }) => {
   const { t } = useTranslation()
   const { t } = useTranslation()
   const isTop = depth === 1
   const isTop = depth === 1
@@ -112,6 +117,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
     isMobile,
     isMobile,
     isInstalledApp,
     isInstalledApp,
     installedAppId,
     installedAppId,
+    controlClearMoreLikeThis,
   }
   }
 
 
   const handleMoreLikeThis = async () => {
   const handleMoreLikeThis = async () => {
@@ -138,6 +144,14 @@ const GenerationItem: FC<IGenerationItemProps> = ({
 
 
     return res
     return res
   })()
   })()
+
+  useEffect(() => {
+    if (controlClearMoreLikeThis) {
+      setChildMessageId(null)
+      setCompletionRes('')
+    }
+  }, [controlClearMoreLikeThis])
+
   return (
   return (
     <div className={cn(className, isTop ? 'rounded-xl border border-gray-200  bg-white' : 'rounded-br-xl !mt-0')}
     <div className={cn(className, isTop ? 'rounded-xl border border-gray-200  bg-white' : 'rounded-br-xl !mt-0')}
       style={isTop
       style={isTop
@@ -155,6 +169,12 @@ const GenerationItem: FC<IGenerationItemProps> = ({
             className={cn(!isTop && 'rounded-br-xl border-l-2 border-primary-400', 'p-4')}
             className={cn(!isTop && 'rounded-br-xl border-l-2 border-primary-400', 'p-4')}
             style={mainStyle}
             style={mainStyle}
           >
           >
+            {(isTop && taskId) && (
+              <div className='mb-2 text-gray-500 border border-gray-200 box-border flex items-center rounded-md italic text-[11px] pl-1 pr-1.5 font-medium w-fit group-hover:opacity-100'>
+                <HashtagIcon className='w-3 h-3 text-gray-400 fill-current mr-1 stroke-current stroke-1' />
+                {taskId}
+              </div>)
+            }
             <Markdown content={content} />
             <Markdown content={content} />
             {messageId && (
             {messageId && (
               <div className='flex items-center justify-between mt-3'>
               <div className='flex items-center justify-between mt-3'>

File diff suppressed because it is too large
+ 24 - 0
web/app/components/base/icons/assets/public/files/csv.svg


+ 3 - 0
web/app/components/base/icons/assets/vender/solid/general/download-02.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="M21 21H3M18 11L12 17M12 17L6 11M12 17V3" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

File diff suppressed because it is too large
+ 181 - 0
web/app/components/base/icons/src/public/files/Csv.json


+ 14 - 0
web/app/components/base/icons/src/public/files/Csv.tsx

@@ -0,0 +1,14 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Csv.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} />)
+
+export default Icon

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

@@ -1 +1,2 @@
+export { default as Csv } from './Csv'
 export { default as Md } from './Md'
 export { default as Md } from './Md'

+ 29 - 0
web/app/components/base/icons/src/vender/solid/general/Download02.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": "M21 21H3M18 11L12 17M12 17L6 11M12 17V3",
+					"stroke": "currentColor",
+					"stroke-width": "2",
+					"stroke-linecap": "round",
+					"stroke-linejoin": "round"
+				},
+				"children": []
+			}
+		]
+	},
+	"name": "Download02"
+}

+ 14 - 0
web/app/components/base/icons/src/vender/solid/general/Download02.tsx

@@ -0,0 +1,14 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Download02.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} />)
+
+export default Icon

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

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

+ 29 - 19
web/app/components/base/tab-header/index.tsx

@@ -1,15 +1,19 @@
 'use client'
 'use client'
-import React, { FC } from 'react'
+import type { FC } from 'react'
+import React from 'react'
 import cn from 'classnames'
 import cn from 'classnames'
 
 
 import s from './style.module.css'
 import s from './style.module.css'
 
 
-export interface ITabHeaderProps {
-  items: {
-    id: string
-    name: string
-    extra?: React.ReactNode
-  }[]
+type Item = {
+  id: string
+  name: string
+  isRight?: boolean
+  extra?: React.ReactNode
+}
+
+export type ITabHeaderProps = {
+  items: Item[]
   value: string
   value: string
   onChange: (value: string) => void
   onChange: (value: string) => void
 }
 }
@@ -17,20 +21,26 @@ export interface ITabHeaderProps {
 const TabHeader: FC<ITabHeaderProps> = ({
 const TabHeader: FC<ITabHeaderProps> = ({
   items,
   items,
   value,
   value,
-  onChange
+  onChange,
 }) => {
 }) => {
+  const renderItem = ({ id, name, extra }: Item) => (
+    <div
+      key={id}
+      className={cn(id === value ? `${s.itemActive} text-gray-900` : 'text-gray-500', 'relative flex items-center pb-1.5 leading-6 cursor-pointer')}
+      onClick={() => onChange(id)}
+    >
+      <div className='text-base font-semibold'>{name}</div>
+      {extra || ''}
+    </div>
+  )
   return (
   return (
-    <div className='flex space-x-4 border-b border-gray-200 '>
-      {items.map(({ id, name, extra }) => (
-        <div
-          key={id}
-          className={cn(id === value ? `${s.itemActive} text-gray-900` : 'text-gray-500', 'relative flex items-center pb-1.5 leading-6 cursor-pointer')}
-          onClick={() => onChange(id)}
-        >
-          <div className='text-base font-semibold'>{name}</div>
-          {extra ? extra : ''}
-        </div>
-      ))}
+    <div className='flex justify-between border-b border-gray-200 '>
+      <div className='flex space-x-4'>
+        {items.filter(item => !item.isRight).map(renderItem)}
+      </div>
+      <div className='flex space-x-4'>
+        {items.filter(item => item.isRight).map(renderItem)}
+      </div>
     </div>
     </div>
   )
   )
 }
 }

+ 221 - 120
web/app/components/share/text-generation/index.tsx

@@ -3,28 +3,44 @@ import type { FC } from 'react'
 import React, { useEffect, useRef, useState } from 'react'
 import React, { useEffect, useRef, useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
 import cn from 'classnames'
 import cn from 'classnames'
-import { useBoolean, useClickAway } from 'ahooks'
+import { useBoolean, useClickAway, useGetState } from 'ahooks'
 import { XMarkIcon } from '@heroicons/react/24/outline'
 import { XMarkIcon } from '@heroicons/react/24/outline'
 import TabHeader from '../../base/tab-header'
 import TabHeader from '../../base/tab-header'
 import Button from '../../base/button'
 import Button from '../../base/button'
 import s from './style.module.css'
 import s from './style.module.css'
+import RunBatch from './run-batch'
 import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
 import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
-import ConfigScence from '@/app/components/share/text-generation/config-scence'
-import NoData from '@/app/components/share/text-generation/no-data'
-// import History from '@/app/components/share/text-generation/history'
-import { fetchSavedMessage as doFetchSavedMessage, fetchAppInfo, fetchAppParams, removeMessage, saveMessage, sendCompletionMessage, updateFeedback } from '@/service/share'
+import RunOnce from '@/app/components/share/text-generation/run-once'
+import { fetchSavedMessage as doFetchSavedMessage, fetchAppInfo, fetchAppParams, removeMessage, saveMessage } from '@/service/share'
 import type { SiteInfo } from '@/models/share'
 import type { SiteInfo } from '@/models/share'
 import type { MoreLikeThisConfig, PromptConfig, SavedMessage } from '@/models/debug'
 import type { MoreLikeThisConfig, PromptConfig, SavedMessage } from '@/models/debug'
-import Toast from '@/app/components/base/toast'
 import AppIcon from '@/app/components/base/app-icon'
 import AppIcon from '@/app/components/base/app-icon'
-import type { Feedbacktype } from '@/app/components/app/chat'
 import { changeLanguage } from '@/i18n/i18next-config'
 import { changeLanguage } from '@/i18n/i18next-config'
 import Loading from '@/app/components/base/loading'
 import Loading from '@/app/components/base/loading'
 import { userInputsFormToPromptVariables } from '@/utils/model-config'
 import { userInputsFormToPromptVariables } from '@/utils/model-config'
-import TextGenerationRes from '@/app/components/app/text-generate/item'
+import Res from '@/app/components/share/text-generation/result'
 import SavedItems from '@/app/components/app/text-generate/saved-items'
 import SavedItems from '@/app/components/app/text-generate/saved-items'
 import type { InstalledApp } from '@/models/explore'
 import type { InstalledApp } from '@/models/explore'
 import { appDefaultIconBackground } from '@/config'
 import { appDefaultIconBackground } from '@/config'
+import Toast from '@/app/components/base/toast'
+
+const PARALLEL_LIMIT = 5
+enum TaskStatus {
+  pending = 'pending',
+  running = 'running',
+  completed = 'completed',
+}
+
+type TaskParam = {
+  inputs: Record<string, any>
+  query: string
+}
+
+type Task = {
+  id: number
+  status: TaskStatus
+  params: TaskParam
+}
 
 
 export type IMainProps = {
 export type IMainProps = {
   isInstalledApp?: boolean
   isInstalledApp?: boolean
@@ -35,134 +51,209 @@ const TextGeneration: FC<IMainProps> = ({
   isInstalledApp = false,
   isInstalledApp = false,
   installedAppInfo,
   installedAppInfo,
 }) => {
 }) => {
+  const { notify } = Toast
+
   const { t } = useTranslation()
   const { t } = useTranslation()
   const media = useBreakpoints()
   const media = useBreakpoints()
   const isPC = media === MediaType.pc
   const isPC = media === MediaType.pc
   const isTablet = media === MediaType.tablet
   const isTablet = media === MediaType.tablet
-  const isMoble = media === MediaType.mobile
+  const isMobile = media === MediaType.mobile
 
 
   const [currTab, setCurrTab] = useState<string>('create')
   const [currTab, setCurrTab] = useState<string>('create')
-
+  // Notice this situation isCallBatchAPI but not in batch tab
+  const [isCallBatchAPI, setIsCallBatchAPI] = useState(false)
+  const isInBatchTab = currTab === 'batch'
   const [inputs, setInputs] = useState<Record<string, any>>({})
   const [inputs, setInputs] = useState<Record<string, any>>({})
+  const [query, setQuery] = useState('') // run once query content
   const [appId, setAppId] = useState<string>('')
   const [appId, setAppId] = useState<string>('')
   const [siteInfo, setSiteInfo] = useState<SiteInfo | null>(null)
   const [siteInfo, setSiteInfo] = useState<SiteInfo | null>(null)
   const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null)
   const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null)
   const [moreLikeThisConfig, setMoreLikeThisConfig] = useState<MoreLikeThisConfig | null>(null)
   const [moreLikeThisConfig, setMoreLikeThisConfig] = useState<MoreLikeThisConfig | null>(null)
-  const [isResponsing, { setTrue: setResponsingTrue, setFalse: setResponsingFalse }] = useBoolean(false)
-  const [query, setQuery] = useState('')
-  const [completionRes, setCompletionRes] = useState('')
-  const { notify } = Toast
-  const isNoData = !completionRes
-
-  const [messageId, setMessageId] = useState<string | null>(null)
-  const [feedback, setFeedback] = useState<Feedbacktype>({
-    rating: null,
-  })
-
-  const handleFeedback = async (feedback: Feedbacktype) => {
-    await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } }, isInstalledApp, installedAppInfo?.id)
-    setFeedback(feedback)
-  }
 
 
+  // save message
   const [savedMessages, setSavedMessages] = useState<SavedMessage[]>([])
   const [savedMessages, setSavedMessages] = useState<SavedMessage[]>([])
-
   const fetchSavedMessage = async () => {
   const fetchSavedMessage = async () => {
     const res: any = await doFetchSavedMessage(isInstalledApp, installedAppInfo?.id)
     const res: any = await doFetchSavedMessage(isInstalledApp, installedAppInfo?.id)
     setSavedMessages(res.data)
     setSavedMessages(res.data)
   }
   }
-
   useEffect(() => {
   useEffect(() => {
     fetchSavedMessage()
     fetchSavedMessage()
   }, [])
   }, [])
-
   const handleSaveMessage = async (messageId: string) => {
   const handleSaveMessage = async (messageId: string) => {
     await saveMessage(messageId, isInstalledApp, installedAppInfo?.id)
     await saveMessage(messageId, isInstalledApp, installedAppInfo?.id)
     notify({ type: 'success', message: t('common.api.saved') })
     notify({ type: 'success', message: t('common.api.saved') })
     fetchSavedMessage()
     fetchSavedMessage()
   }
   }
-
   const handleRemoveSavedMessage = async (messageId: string) => {
   const handleRemoveSavedMessage = async (messageId: string) => {
     await removeMessage(messageId, isInstalledApp, installedAppInfo?.id)
     await removeMessage(messageId, isInstalledApp, installedAppInfo?.id)
     notify({ type: 'success', message: t('common.api.remove') })
     notify({ type: 'success', message: t('common.api.remove') })
     fetchSavedMessage()
     fetchSavedMessage()
   }
   }
 
 
-  const logError = (message: string) => {
-    notify({ type: 'error', message })
+  // send message task
+  const [controlSend, setControlSend] = useState(0)
+  const [controlStopResponding, setControlStopResponding] = useState(0)
+  const handleSend = () => {
+    setIsCallBatchAPI(false)
+    setControlSend(Date.now())
+    // eslint-disable-next-line @typescript-eslint/no-use-before-define
+    setAllTaskList([]) // clear batch task running status
   }
   }
 
 
-  const checkCanSend = () => {
-    const prompt_variables = promptConfig?.prompt_variables
-    if (!prompt_variables || prompt_variables?.length === 0)
-      return true
-
-    let hasEmptyInput = false
-    const requiredVars = prompt_variables?.filter(({ key, name, required }) => {
-      const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
-      return res
-    }) || [] // compatible with old version
-    requiredVars.forEach(({ key }) => {
-      if (hasEmptyInput)
+  const [allTaskList, setAllTaskList, getLatestTaskList] = useGetState<Task[]>([])
+  const pendingTaskList = allTaskList.filter(task => task.status === TaskStatus.pending)
+  const noPendingTask = pendingTaskList.length === 0
+  const showTaskList = allTaskList.filter(task => task.status !== TaskStatus.pending)
+  const allTaskFinished = allTaskList.every(task => task.status === TaskStatus.completed)
+  const checkBatchInputs = (data: string[][]) => {
+    if (!data || data.length === 0) {
+      notify({ type: 'error', message: t('share.generation.errorMsg.empty') })
+      return false
+    }
+    const headerData = data[0]
+    const varLen = promptConfig?.prompt_variables.length || 0
+    let isMapVarName = true
+    promptConfig?.prompt_variables.forEach((item, index) => {
+      if (!isMapVarName)
         return
         return
 
 
-      if (!inputs[key])
-        hasEmptyInput = true
+      if (item.name !== headerData[index])
+        isMapVarName = false
     })
     })
 
 
-    if (hasEmptyInput) {
-      logError(t('appDebug.errorMessage.valueOfVarRequired'))
+    if (headerData[varLen] !== t('share.generation.queryTitle'))
+      isMapVarName = false
+
+    if (!isMapVarName) {
+      notify({ type: 'error', message: t('share.generation.errorMsg.fileStructNotMatch') })
       return false
       return false
     }
     }
-    return !hasEmptyInput
-  }
 
 
-  const handleSend = async () => {
-    if (isResponsing) {
-      notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') })
+    let payloadData = data.slice(1)
+    if (payloadData.length === 0) {
+      notify({ type: 'error', message: t('share.generation.errorMsg.atLeastOne') })
       return false
       return false
     }
     }
 
 
-    if (!checkCanSend())
-      return
+    // 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('share.generation.errorMsg.emptyLine', { rowIndex: startIndex + 2 }) })
+        return false
+      }
+    }
 
 
-    if (!query) {
-      logError(t('appDebug.errorMessage.queryRequired'))
+    // 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('share.generation.errorMsg.atLeastOne') })
       return false
       return false
     }
     }
+    let errorRowIndex = 0
+    let requiredVarName = ''
+    payloadData.forEach((item, index) => {
+      if (errorRowIndex !== 0)
+        return
+
+      promptConfig?.prompt_variables.forEach((varItem, varIndex) => {
+        if (errorRowIndex !== 0)
+          return
+        if (varItem.required === false)
+          return
+
+        if (item[varIndex].trim() === '') {
+          requiredVarName = varItem.name
+          errorRowIndex = index + 1
+        }
+      })
+
+      if (errorRowIndex !== 0)
+        return
+
+      if (item[varLen] === '') {
+        requiredVarName = t('share.generation.queryTitle')
+        errorRowIndex = index + 1
+      }
+    })
 
 
-    const data = {
-      inputs,
-      query,
+    if (errorRowIndex !== 0) {
+      notify({ type: 'error', message: t('share.generation.errorMsg.invalidLine', { rowIndex: errorRowIndex + 1, varName: requiredVarName }) })
+      return false
     }
     }
+    return true
+  }
+  const handleRunBatch = (data: string[][]) => {
+    if (!checkBatchInputs(data))
+      return
+    if (!allTaskFinished) {
+      notify({ type: 'info', message: t('appDebug.errorMessage.waitForBatchResponse') })
+      return
+    }
+
+    const payloadData = data.filter(item => !item.every(i => i === '')).slice(1)
+    const varLen = promptConfig?.prompt_variables.length || 0
+    setIsCallBatchAPI(true)
+    const allTaskList: Task[] = payloadData.map((item, i) => {
+      const inputs: Record<string, string> = {}
+      if (varLen > 0) {
+        item.slice(0, varLen).forEach((input, index) => {
+          inputs[promptConfig?.prompt_variables[index].key as string] = input
+        })
+      }
+      return {
+        id: i + 1,
+        status: i < PARALLEL_LIMIT ? TaskStatus.running : TaskStatus.pending,
+        params: {
+          inputs,
+          query: item[varLen],
+        },
+      }
+    })
+    setAllTaskList(allTaskList)
+
+    setControlSend(Date.now())
+    // clear run once task status
+    setControlStopResponding(Date.now())
+  }
 
 
-    setMessageId(null)
-    setFeedback({
-      rating: null,
+  const handleCompleted = (taskId?: number, isSuccess?: boolean) => {
+    // console.log(taskId, isSuccess)
+    const allTasklistLatest = getLatestTaskList()
+    const pendingTaskList = allTasklistLatest.filter(task => task.status === TaskStatus.pending)
+    const nextPendingTaskId = pendingTaskList[0]?.id
+    // console.log(`start: ${allTasklistLatest.map(item => item.status).join(',')}`)
+    const newAllTaskList = allTasklistLatest.map((item) => {
+      if (item.id === taskId) {
+        return {
+          ...item,
+          status: TaskStatus.completed,
+        }
+      }
+      if (item.id === nextPendingTaskId) {
+        return {
+          ...item,
+          status: TaskStatus.running,
+        }
+      }
+      return item
     })
     })
-    setCompletionRes('')
-
-    const res: string[] = []
-    let tempMessageId = ''
-
-    if (!isPC)
-      // eslint-disable-next-line @typescript-eslint/no-use-before-define
-      showResSidebar()
-
-    setResponsingTrue()
-    sendCompletionMessage(data, {
-      onData: (data: string, _isFirstMessage: boolean, { messageId }: any) => {
-        tempMessageId = messageId
-        res.push(data)
-        setCompletionRes(res.join(''))
-      },
-      onCompleted: () => {
-        setResponsingFalse()
-        setMessageId(tempMessageId)
-      },
-      onError() {
-        setResponsingFalse()
-      },
-    }, isInstalledApp, installedAppInfo?.id)
+    // console.log(`end: ${newAllTaskList.map(item => item.status).join(',')}`)
+    setAllTaskList(newAllTaskList)
   }
   }
 
 
   const fetchInitData = () => {
   const fetchInitData = () => {
@@ -209,14 +300,37 @@ const TextGeneration: FC<IMainProps> = ({
     hideResSidebar()
     hideResSidebar()
   }, resRef)
   }, resRef)
 
 
-  const renderRes = (
+  const renderRes = (task?: Task) => (<Res
+    key={task?.id}
+    isCallBatchAPI={isCallBatchAPI}
+    isPC={isPC}
+    isMobile={isMobile}
+    isInstalledApp={!!isInstalledApp}
+    installedAppInfo={installedAppInfo}
+    promptConfig={promptConfig}
+    moreLikeThisEnabled={!!moreLikeThisConfig?.enabled}
+    inputs={isCallBatchAPI ? (task as Task).params.inputs : inputs}
+    query={isCallBatchAPI ? (task as Task).params.query : query}
+    controlSend={controlSend}
+    controlStopResponding={controlStopResponding}
+    onShowRes={showResSidebar}
+    handleSaveMessage={handleSaveMessage}
+    taskId={task?.id}
+    onCompleted={handleCompleted}
+  />)
+
+  const renderBatchRes = () => {
+    return (showTaskList.map(task => renderRes(task)))
+  }
+
+  const renderResWrap = (
     <div
     <div
       ref={resRef}
       ref={resRef}
       className={
       className={
         cn(
         cn(
           'flex flex-col h-full shrink-0',
           'flex flex-col h-full shrink-0',
           isPC ? 'px-10 py-8' : 'bg-gray-50',
           isPC ? 'px-10 py-8' : 'bg-gray-50',
-          isTablet && 'p-6', isMoble && 'p-4')
+          isTablet && 'p-6', isMobile && 'p-4')
       }
       }
     >
     >
       <>
       <>
@@ -236,33 +350,12 @@ const TextGeneration: FC<IMainProps> = ({
         </div>
         </div>
 
 
         <div className='grow overflow-y-auto'>
         <div className='grow overflow-y-auto'>
-          {(isResponsing && !completionRes)
-            ? (
-              <div className='flex h-full w-full justify-center items-center'>
-                <Loading type='area' />
-              </div>)
-            : (
-              <>
-                {isNoData
-                  ? <NoData />
-                  : (
-                    <TextGenerationRes
-                      className='mt-3'
-                      content={completionRes}
-                      messageId={messageId}
-                      isInWebApp
-                      moreLikeThis={moreLikeThisConfig?.enabled}
-                      onFeedback={handleFeedback}
-                      feedback={feedback}
-                      onSave={handleSaveMessage}
-                      isMobile={isMoble}
-                      isInstalledApp={isInstalledApp}
-                      installedAppId={installedAppInfo?.id}
-                    />
-                  )
-                }
-              </>
-            )}
+          {!isCallBatchAPI ? renderRes() : renderBatchRes()}
+          {!noPendingTask && (
+            <div className='mt-4'>
+              <Loading type='area' />
+            </div>
+          )}
         </div>
         </div>
       </>
       </>
     </div>
     </div>
@@ -309,9 +402,11 @@ const TextGeneration: FC<IMainProps> = ({
           <TabHeader
           <TabHeader
             items={[
             items={[
               { id: 'create', name: t('share.generation.tabs.create') },
               { id: 'create', name: t('share.generation.tabs.create') },
+              { id: 'batch', name: t('share.generation.tabs.batch') },
               {
               {
                 id: 'saved',
                 id: 'saved',
                 name: t('share.generation.tabs.saved'),
                 name: t('share.generation.tabs.saved'),
+                isRight: true,
                 extra: savedMessages.length > 0
                 extra: savedMessages.length > 0
                   ? (
                   ? (
                     <div className='ml-1 flext items-center h-5 px-1.5 rounded-md border border-gray-200 text-gray-500 text-xs font-medium'>
                     <div className='ml-1 flext items-center h-5 px-1.5 rounded-md border border-gray-200 text-gray-500 text-xs font-medium'>
@@ -325,8 +420,8 @@ const TextGeneration: FC<IMainProps> = ({
             onChange={setCurrTab}
             onChange={setCurrTab}
           />
           />
           <div className='grow h-20 overflow-y-auto'>
           <div className='grow h-20 overflow-y-auto'>
-            {currTab === 'create' && (
-              <ConfigScence
+            <div className={cn(currTab === 'create' ? 'block' : 'hidden')}>
+              <RunOnce
                 siteInfo={siteInfo}
                 siteInfo={siteInfo}
                 inputs={inputs}
                 inputs={inputs}
                 onInputsChange={setInputs}
                 onInputsChange={setInputs}
@@ -335,7 +430,13 @@ const TextGeneration: FC<IMainProps> = ({
                 onQueryChange={setQuery}
                 onQueryChange={setQuery}
                 onSend={handleSend}
                 onSend={handleSend}
               />
               />
-            )}
+            </div>
+            <div className={cn(isInBatchTab ? 'block' : 'hidden')}>
+              <RunBatch
+                vars={promptConfig.prompt_variables}
+                onSend={handleRunBatch}
+              />
+            </div>
 
 
             {currTab === 'saved' && (
             {currTab === 'saved' && (
               <SavedItems
               <SavedItems
@@ -371,7 +472,7 @@ const TextGeneration: FC<IMainProps> = ({
         {/* Result */}
         {/* Result */}
         {isPC && (
         {isPC && (
           <div className='grow h-full'>
           <div className='grow h-full'>
-            {renderRes}
+            {renderResWrap}
           </div>
           </div>
         )}
         )}
 
 
@@ -382,7 +483,7 @@ const TextGeneration: FC<IMainProps> = ({
               background: 'rgba(35, 56, 118, 0.2)',
               background: 'rgba(35, 56, 118, 0.2)',
             }}
             }}
           >
           >
-            {renderRes}
+            {renderResWrap}
           </div>
           </div>
         )}
         )}
       </div>
       </div>

+ 34 - 0
web/app/components/share/text-generation/result/content.tsx

@@ -0,0 +1,34 @@
+import type { FC } from 'react'
+import React from 'react'
+import Header from './header'
+import type { Feedbacktype } from '@/app/components/app/chat'
+import { format } from '@/service/base'
+
+export type IResultProps = {
+  content: string
+  showFeedback: boolean
+  feedback: Feedbacktype
+  onFeedback: (feedback: Feedbacktype) => void
+}
+const Result: FC<IResultProps> = ({
+  content,
+  showFeedback,
+  feedback,
+  onFeedback,
+}) => {
+  return (
+    <div className='basis-3/4 h-max'>
+      <Header result={content} showFeedback={showFeedback} feedback={feedback} onFeedback={onFeedback} />
+      <div
+        className='mt-4 w-full flex text-sm leading-5 overflow-scroll font-normal text-gray-900'
+        style={{
+          maxHeight: '70vh',
+        }}
+        dangerouslySetInnerHTML={{
+          __html: format(content),
+        }}
+      ></div>
+    </div>
+  )
+}
+export default React.memo(Result)

+ 195 - 24
web/app/components/share/text-generation/result/index.tsx

@@ -1,33 +1,204 @@
+'use client'
 import type { FC } from 'react'
 import type { FC } from 'react'
-import React from 'react'
-import Header from './header'
-import { Feedbacktype } from '@/app/components/app/chat'
-import { format } from '@/service/base'
-
+import React, { useEffect, useState } from 'react'
+import { useBoolean } from 'ahooks'
+import { t } from 'i18next'
+import cn from 'classnames'
+import TextGenerationRes from '@/app/components/app/text-generate/item'
+import NoData from '@/app/components/share/text-generation/no-data'
+import Toast from '@/app/components/base/toast'
+import { sendCompletionMessage, updateFeedback } from '@/service/share'
+import type { Feedbacktype } from '@/app/components/app/chat'
+import Loading from '@/app/components/base/loading'
+import type { PromptConfig } from '@/models/debug'
+import type { InstalledApp } from '@/models/explore'
 export type IResultProps = {
 export type IResultProps = {
-  content: string
-  showFeedback: boolean
-  feedback: Feedbacktype
-  onFeedback: (feedback: Feedbacktype) => void
+  isCallBatchAPI: boolean
+  isPC: boolean
+  isMobile: boolean
+  isInstalledApp: boolean
+  installedAppInfo?: InstalledApp
+  promptConfig: PromptConfig | null
+  moreLikeThisEnabled: boolean
+  inputs: Record<string, any>
+  query: string
+  controlSend?: number
+  controlStopResponding?: number
+  onShowRes: () => void
+  handleSaveMessage: (messageId: string) => void
+  taskId?: number
+  onCompleted: (taskId?: number, success?: boolean) => void
 }
 }
+
 const Result: FC<IResultProps> = ({
 const Result: FC<IResultProps> = ({
-  content,
-  showFeedback,
-  feedback,
-  onFeedback
+  isCallBatchAPI,
+  isPC,
+  isMobile,
+  isInstalledApp,
+  installedAppInfo,
+  promptConfig,
+  moreLikeThisEnabled,
+  inputs,
+  query,
+  controlSend,
+  controlStopResponding,
+  onShowRes,
+  handleSaveMessage,
+  taskId,
+  onCompleted,
 }) => {
 }) => {
+  const [isResponsing, { setTrue: setResponsingTrue, setFalse: setResponsingFalse }] = useBoolean(false)
+  useEffect(() => {
+    if (controlStopResponding)
+      setResponsingFalse()
+  }, [controlStopResponding])
+
+  const [completionRes, setCompletionRes] = useState('')
+  const { notify } = Toast
+  const isNoData = !completionRes
+
+  const [messageId, setMessageId] = useState<string | null>(null)
+  const [feedback, setFeedback] = useState<Feedbacktype>({
+    rating: null,
+  })
+
+  const handleFeedback = async (feedback: Feedbacktype) => {
+    await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } }, isInstalledApp, installedAppInfo?.id)
+    setFeedback(feedback)
+  }
+
+  const logError = (message: string) => {
+    notify({ type: 'error', message })
+  }
+
+  const checkCanSend = () => {
+    // batch will check outer
+    if (isCallBatchAPI)
+      return true
+
+    const prompt_variables = promptConfig?.prompt_variables
+    if (!prompt_variables || prompt_variables?.length === 0)
+      return true
+
+    let hasEmptyInput = false
+    const requiredVars = prompt_variables?.filter(({ key, name, required }) => {
+      const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
+      return res
+    }) || [] // compatible with old version
+    requiredVars.forEach(({ key }) => {
+      if (hasEmptyInput)
+        return
+
+      if (!inputs[key])
+        hasEmptyInput = true
+    })
+
+    if (hasEmptyInput) {
+      logError(t('appDebug.errorMessage.valueOfVarRequired'))
+      return false
+    }
+    return !hasEmptyInput
+  }
+
+  const handleSend = async () => {
+    if (isResponsing) {
+      notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') })
+      return false
+    }
+
+    if (!checkCanSend())
+      return
+
+    if (!query) {
+      logError(t('appDebug.errorMessage.queryRequired'))
+      return false
+    }
+
+    const data = {
+      inputs,
+      query,
+    }
+
+    setMessageId(null)
+    setFeedback({
+      rating: null,
+    })
+    setCompletionRes('')
+
+    const res: string[] = []
+    let tempMessageId = ''
+
+    if (!isPC)
+      onShowRes()
+
+    setResponsingTrue()
+    sendCompletionMessage(data, {
+      onData: (data: string, _isFirstMessage: boolean, { messageId }: any) => {
+        tempMessageId = messageId
+        res.push(data)
+        setCompletionRes(res.join(''))
+      },
+      onCompleted: () => {
+        setResponsingFalse()
+        setMessageId(tempMessageId)
+        onCompleted(taskId, true)
+      },
+      onError() {
+        setResponsingFalse()
+        onCompleted(taskId, false)
+      },
+    }, isInstalledApp, installedAppInfo?.id)
+  }
+
+  const [controlClearMoreLikeThis, setControlClearMoreLikeThis] = useState(0)
+  useEffect(() => {
+    if (controlSend) {
+      handleSend()
+      setControlClearMoreLikeThis(Date.now())
+    }
+  }, [controlSend])
+
+  const renderTextGenerationRes = () => (
+    <TextGenerationRes
+      className='mt-3'
+      content={completionRes}
+      messageId={messageId}
+      isInWebApp
+      moreLikeThis={moreLikeThisEnabled}
+      onFeedback={handleFeedback}
+      feedback={feedback}
+      onSave={handleSaveMessage}
+      isMobile={isMobile}
+      isInstalledApp={isInstalledApp}
+      installedAppId={installedAppInfo?.id}
+      isLoading={isCallBatchAPI ? (!completionRes && isResponsing) : false}
+      taskId={isCallBatchAPI ? ((taskId as number) < 10 ? `0${taskId}` : `${taskId}`) : undefined}
+      controlClearMoreLikeThis={controlClearMoreLikeThis}
+    />
+  )
+
   return (
   return (
-    <div className='basis-3/4 h-max'>
-      <Header result={content} showFeedback={showFeedback} feedback={feedback} onFeedback={onFeedback} />
-      <div
-        className='mt-4 w-full flex text-sm leading-5 overflow-scroll font-normal text-gray-900'
-        style={{
-          maxHeight: '70vh'
-        }}
-        dangerouslySetInnerHTML={{
-          __html: format(content)
-        }}
-      ></div>
+    <div className={cn(isNoData && !isCallBatchAPI && 'h-full')}>
+      {!isCallBatchAPI && (
+        (isResponsing && !completionRes)
+          ? (
+            <div className='flex h-full w-full justify-center items-center'>
+              <Loading type='area' />
+            </div>)
+          : (
+            <>
+              {isNoData
+                ? <NoData />
+                : renderTextGenerationRes()
+              }
+            </>
+          )
+      )}
+      {isCallBatchAPI && (
+        <div className='mt-2'>
+          {renderTextGenerationRes()}
+        </div>
+      )}
     </div>
     </div>
   )
   )
 }
 }

+ 70 - 0
web/app/components/share/text-generation/run-batch/csv-download/index.tsx

@@ -0,0 +1,70 @@
+'use client'
+import type { FC } from 'react'
+import React from 'react'
+import {
+  useCSVDownloader,
+} from 'react-papaparse'
+import { useTranslation } from 'react-i18next'
+import { Download02 as DownloadIcon } from '@/app/components/base/icons/src/vender/solid/general'
+
+export type ICSVDownloadProps = {
+  vars: { name: string }[]
+}
+
+const CSVDownload: FC<ICSVDownloadProps> = ({
+  vars,
+}) => {
+  const { t } = useTranslation()
+  const { CSVDownloader, Type } = useCSVDownloader()
+  const addQueryContentVars = [...vars, { name: t('share.generation.queryTitle') }]
+  const template = (() => {
+    const res: Record<string, string> = {}
+    addQueryContentVars.forEach((item) => {
+      res[item.name] = ''
+    })
+    return res
+  })()
+
+  return (
+    <div className='mt-6'>
+      <div className='text-sm text-gray-900 font-medium'>{t('share.generation.csvStructureTitle')}</div>
+      <div className='mt-2 max-h-[500px] overflow-auto'>
+        <table className='w-full border-separate border-spacing-0 border border-gray-200 rounded-lg text-xs'>
+          <thead className='text-gray-500'>
+            <tr>
+              {addQueryContentVars.map((item, i) => (
+                <td key={i} className='h-9 pl-4 border-b border-gray-200'>{item.name}</td>
+              ))}
+            </tr>
+          </thead>
+          <tbody className='text-gray-300'>
+            <tr>
+              {addQueryContentVars.map((item, i) => (
+                <td key={i} className='h-9 pl-4'>{item.name} {t('share.generation.field')}</td>
+              ))}
+            </tr>
+          </tbody>
+        </table>
+      </div>
+      <CSVDownloader
+        className="block mt-2 cursor-pointer"
+        type={Type.Link}
+        filename={'template'}
+        bom={true}
+        config={{
+          // delimiter: ';',
+        }}
+        data={[
+          template,
+        ]}
+      >
+        <div className='flex items-center h-[18px] space-x-1 text-[#155EEF] text-xs font-medium'>
+          <DownloadIcon className='w-3 h-3' />
+          <span>{t('share.generation.downloadTemplate')}</span>
+        </div>
+      </CSVDownloader>
+    </div>
+
+  )
+}
+export default React.memo(CSVDownload)

+ 70 - 0
web/app/components/share/text-generation/run-batch/csv-reader/index.tsx

@@ -0,0 +1,70 @@
+'use client'
+import type { FC } from 'react'
+import React, { useState } from 'react'
+import {
+  useCSVReader,
+} from 'react-papaparse'
+import cn from 'classnames'
+import { useTranslation } from 'react-i18next'
+import s from './style.module.css'
+import { Csv as CSVIcon } from '@/app/components/base/icons/src/public/files'
+
+export type Props = {
+  onParsed: (data: string[][]) => void
+}
+
+const CSVReader: FC<Props> = ({
+  onParsed,
+}) => {
+  const { t } = useTranslation()
+  const { CSVReader } = useCSVReader()
+  const [zoneHover, setZoneHover] = useState(false)
+  return (
+    <CSVReader
+      onUploadAccepted={(results: any) => {
+        onParsed(results.data)
+        setZoneHover(false)
+      }}
+      onDragOver={(event: DragEvent) => {
+        event.preventDefault()
+        setZoneHover(true)
+      }}
+      onDragLeave={(event: DragEvent) => {
+        event.preventDefault()
+        setZoneHover(false)
+      }}
+    >
+      {({
+        getRootProps,
+        acceptedFile,
+      }: any) => (
+        <>
+          <div
+            {...getRootProps()}
+            className={cn(s.zone, zoneHover && s.zoneHover, acceptedFile ? 'px-6' : 'justify-center border-dashed text-gray-500')}
+          >
+            {
+              acceptedFile
+                ? (
+                  <div className='w-full flex items-center space-x-2'>
+                    <CSVIcon className="shrink-0" />
+                    <div className='flex w-0 grow'>
+                      <span className='max-w-[calc(100%_-_30px)] text-ellipsis whitespace-nowrap overflow-hidden text-gray-800'>{acceptedFile.name.replace(/.csv$/, '')}</span>
+                      <span className='shrink-0 text-gray-500'>.csv</span>
+                    </div>
+                  </div>
+                )
+                : (
+                  <div className='flex items-center justify-center space-x-2'>
+                    <CSVIcon className="shrink-0" />
+                    <div className='text-gray-500'>{t('share.generation.csvUploadTitle')}<span className='text-primary-400'>{t('share.generation.browse')}</span></div>
+                  </div>
+                )}
+          </div>
+        </>
+      )}
+    </CSVReader>
+  )
+}
+
+export default React.memo(CSVReader)

+ 11 - 0
web/app/components/share/text-generation/run-batch/csv-reader/style.module.css

@@ -0,0 +1,11 @@
+.zone {
+    @apply flex items-center h-20 rounded-xl bg-gray-50 border border-gray-200 cursor-pointer text-sm font-normal;
+}
+
+.zoneHover {
+    @apply border-solid bg-gray-100;
+}
+
+.info {
+    @apply text-gray-800 text-sm;
+}

+ 53 - 0
web/app/components/share/text-generation/run-batch/index.tsx

@@ -0,0 +1,53 @@
+'use client'
+import type { FC } from 'react'
+import React from 'react'
+import {
+  PlayIcon,
+} from '@heroicons/react/24/solid'
+import { useTranslation } from 'react-i18next'
+import CSVReader from './csv-reader'
+import CSVDownload from './csv-download'
+import Button from '@/app/components/base/button'
+
+export type IRunBatchProps = {
+  vars: { name: string }[]
+  onSend: (data: string[][]) => void
+}
+
+const RunBatch: FC<IRunBatchProps> = ({
+  vars,
+  onSend,
+}) => {
+  const { t } = useTranslation()
+
+  const [csvData, setCsvData] = React.useState<string[][]>([])
+  const [isParsed, setIsParsed] = React.useState(false)
+  const handleParsed = (data: string[][]) => {
+    setCsvData(data)
+    // console.log(data)
+    setIsParsed(true)
+  }
+
+  const handleSend = () => {
+    onSend(csvData)
+  }
+  return (
+    <div className='pt-4'>
+      <CSVReader onParsed={handleParsed} />
+      <CSVDownload vars={vars} />
+      <div className='mt-4 h-[1px] bg-gray-100'></div>
+      <div className='flex justify-end'>
+        <Button
+          type="primary"
+          className='mt-4 !h-8 !pl-3 !pr-4'
+          onClick={handleSend}
+          disabled={!isParsed}
+        >
+          <PlayIcon className="shrink-0 w-4 h-4 mr-1" aria-hidden="true" />
+          <span className='uppercase text-[13px]'>{t('share.generation.run')}</span>
+        </Button>
+      </div>
+    </div>
+  )
+}
+export default React.memo(RunBatch)

+ 4 - 4
web/app/components/share/text-generation/config-scence/index.tsx

@@ -10,7 +10,7 @@ import type { PromptConfig } from '@/models/debug'
 import Button from '@/app/components/base/button'
 import Button from '@/app/components/base/button'
 import { DEFAULT_VALUE_MAX_LEN } from '@/config'
 import { DEFAULT_VALUE_MAX_LEN } from '@/config'
 
 
-export type IConfigSenceProps = {
+export type IRunOnceProps = {
   siteInfo: SiteInfo
   siteInfo: SiteInfo
   promptConfig: PromptConfig
   promptConfig: PromptConfig
   inputs: Record<string, any>
   inputs: Record<string, any>
@@ -19,7 +19,7 @@ export type IConfigSenceProps = {
   onQueryChange: (query: string) => void
   onQueryChange: (query: string) => void
   onSend: () => void
   onSend: () => void
 }
 }
-const ConfigSence: FC<IConfigSenceProps> = ({
+const RunOnce: FC<IRunOnceProps> = ({
   promptConfig,
   promptConfig,
   inputs,
   inputs,
   onInputsChange,
   onInputsChange,
@@ -85,7 +85,7 @@ const ConfigSence: FC<IConfigSenceProps> = ({
                 </div>
                 </div>
                 <Button
                 <Button
                   type="primary"
                   type="primary"
-                  className='w-[80px] !h-8 !p-0'
+                  className='!h-8 !pl-3 !pr-4'
                   onClick={onSend}
                   onClick={onSend}
                   disabled={!query || query === ''}
                   disabled={!query || query === ''}
                 >
                 >
@@ -100,4 +100,4 @@ const ConfigSence: FC<IConfigSenceProps> = ({
     </div>
     </div>
   )
   )
 }
 }
-export default React.memo(ConfigSence)
+export default React.memo(RunOnce)

+ 2 - 0
web/i18n/lang/app-debug.en.ts

@@ -86,6 +86,8 @@ const translation = {
     queryRequired: 'Request text is required.',
     queryRequired: 'Request text is required.',
     waitForResponse:
     waitForResponse:
       'Please wait for the response to the previous message to complete.',
       'Please wait for the response to the previous message to complete.',
+    waitForBatchResponse:
+      'Please wait for the response to the batch task to complete.',
   },
   },
   chatSubTitle: 'Pre Prompt',
   chatSubTitle: 'Pre Prompt',
   completionSubTitle: 'Prefix Prompt',
   completionSubTitle: 'Prefix Prompt',

+ 1 - 0
web/i18n/lang/app-debug.zh.ts

@@ -84,6 +84,7 @@ const translation = {
     valueOfVarRequired: '变量值必填',
     valueOfVarRequired: '变量值必填',
     queryRequired: '主要文本必填',
     queryRequired: '主要文本必填',
     waitForResponse: '请等待上条信息响应完成',
     waitForResponse: '请等待上条信息响应完成',
+    waitForBatchResponse: '请等待批量任务完成',
   },
   },
   chatSubTitle: '对话前提示词',
   chatSubTitle: '对话前提示词',
   completionSubTitle: '前缀提示词',
   completionSubTitle: '前缀提示词',

+ 15 - 2
web/i18n/lang/share-app.en.ts

@@ -30,7 +30,8 @@ const translation = {
   },
   },
   generation: {
   generation: {
     tabs: {
     tabs: {
-      create: 'Create',
+      create: 'Run Once',
+      batch: 'Run Batch',
       saved: 'Saved',
       saved: 'Saved',
     },
     },
     savedNoData: {
     savedNoData: {
@@ -41,10 +42,22 @@ const translation = {
     title: 'AI Completion',
     title: 'AI Completion',
     queryTitle: 'Query content',
     queryTitle: 'Query content',
     queryPlaceholder: 'Write your query content...',
     queryPlaceholder: 'Write your query content...',
-    run: 'RUN',
+    run: 'Execute',
     copy: 'Copy',
     copy: 'Copy',
     resultTitle: 'AI Completion',
     resultTitle: 'AI Completion',
     noData: 'AI will give you what you want here.',
     noData: 'AI will give you what you want here.',
+    csvUploadTitle: 'Drag and drop your CSV file here, or ',
+    browse: 'browse',
+    csvStructureTitle: 'The CSV file must conform to the following structure:',
+    downloadTemplate: 'Download the template here',
+    field: 'Field',
+    errorMsg: {
+      empty: 'Please input content in the uploaded file.',
+      fileStructNotMatch: 'The uploaded CSV file not match the struct.',
+      emptyLine: 'Row {{rowIndex}} is empty',
+      invalidLine: 'Row {{rowIndex}}: variables value can not be empty',
+      atLeastOne: 'Please input at least one row in the uploaded file.',
+    },
   },
   },
 }
 }
 
 

+ 14 - 1
web/i18n/lang/share-app.zh.ts

@@ -26,7 +26,8 @@ const translation = {
   },
   },
   generation: {
   generation: {
     tabs: {
     tabs: {
-      create: '创建',
+      create: '运行一次',
+      batch: '批量运行',
       saved: '已保存',
       saved: '已保存',
     },
     },
     savedNoData: {
     savedNoData: {
@@ -41,6 +42,18 @@ const translation = {
     copy: '拷贝',
     copy: '拷贝',
     resultTitle: 'AI 书写',
     resultTitle: 'AI 书写',
     noData: 'AI 会在这里给你惊喜。',
     noData: 'AI 会在这里给你惊喜。',
+    csvUploadTitle: '将您的 CSV 文件拖放到此处,或',
+    browse: '浏览',
+    csvStructureTitle: 'CSV 文件必须符合以下结构:',
+    downloadTemplate: '下载模板',
+    field: '',
+    errorMsg: {
+      empty: '上传文件的内容不能为空',
+      fileStructNotMatch: '上传文件的内容与结构不匹配',
+      emptyLine: '第 {{rowIndex}} 行的内容为空',
+      invalidLine: '第 {{rowIndex}} 行: 变量值必填',
+      atLeastOne: '上传文件的内容不能少于一条',
+    },
   },
   },
 }
 }
 
 

+ 1 - 0
web/package.json

@@ -61,6 +61,7 @@
     "react-i18next": "^12.2.0",
     "react-i18next": "^12.2.0",
     "react-infinite-scroll-component": "^6.1.0",
     "react-infinite-scroll-component": "^6.1.0",
     "react-markdown": "^8.0.6",
     "react-markdown": "^8.0.6",
+    "react-papaparse": "^4.1.0",
     "react-slider": "^2.0.4",
     "react-slider": "^2.0.4",
     "react-sortablejs": "^6.1.4",
     "react-sortablejs": "^6.1.4",
     "react-syntax-highlighter": "^15.5.0",
     "react-syntax-highlighter": "^15.5.0",

+ 1 - 1
web/service/base.ts

@@ -308,13 +308,13 @@ export const ssePost = (url: string, fetchOptions: any, { isPublicAPI = false, o
       }
       }
       return handleStream(res, (str: string, isFirstMessage: boolean, moreInfo: IOnDataMoreInfo) => {
       return handleStream(res, (str: string, isFirstMessage: boolean, moreInfo: IOnDataMoreInfo) => {
         if (moreInfo.errorMessage) {
         if (moreInfo.errorMessage) {
+          onError?.(moreInfo.errorMessage)
           Toast.notify({ type: 'error', message: moreInfo.errorMessage })
           Toast.notify({ type: 'error', message: moreInfo.errorMessage })
           return
           return
         }
         }
         onData?.(str, isFirstMessage, moreInfo)
         onData?.(str, isFirstMessage, moreInfo)
       }, onCompleted)
       }, onCompleted)
     }).catch((e) => {
     }).catch((e) => {
-      // debugger
       Toast.notify({ type: 'error', message: e })
       Toast.notify({ type: 'error', message: e })
       onError?.(e)
       onError?.(e)
     })
     })