Ver Fonte

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

Joel há 1 ano atrás
pai
commit
11baff6740
24 ficheiros alterados com 1009 adições e 172 exclusões
  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/run-once/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'
 import type { FC } from 'react'
-import React, { useState } from 'react'
+import React, { useEffect, useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import cn from 'classnames'
 import copy from 'copy-to-clipboard'
 import { HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline'
 import { useBoolean } from 'ahooks'
+import { HashtagIcon } from '@heroicons/react/24/solid'
 import { Markdown } from '@/app/components/base/markdown'
 import Loading from '@/app/components/base/loading'
 import Toast from '@/app/components/base/toast'
@@ -27,6 +28,8 @@ export type IGenerationItemProps = {
   isMobile?: boolean
   isInstalledApp: boolean
   installedAppId?: string
+  taskId?: string
+  controlClearMoreLikeThis?: number
 }
 
 export const SimpleBtn = ({ className, onClick, children }: {
@@ -81,6 +84,8 @@ const GenerationItem: FC<IGenerationItemProps> = ({
   isMobile,
   isInstalledApp,
   installedAppId,
+  taskId,
+  controlClearMoreLikeThis,
 }) => {
   const { t } = useTranslation()
   const isTop = depth === 1
@@ -112,6 +117,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
     isMobile,
     isInstalledApp,
     installedAppId,
+    controlClearMoreLikeThis,
   }
 
   const handleMoreLikeThis = async () => {
@@ -138,6 +144,14 @@ const GenerationItem: FC<IGenerationItemProps> = ({
 
     return res
   })()
+
+  useEffect(() => {
+    if (controlClearMoreLikeThis) {
+      setChildMessageId(null)
+      setCompletionRes('')
+    }
+  }, [controlClearMoreLikeThis])
+
   return (
     <div className={cn(className, isTop ? 'rounded-xl border border-gray-200  bg-white' : 'rounded-br-xl !mt-0')}
       style={isTop
@@ -155,6 +169,12 @@ const GenerationItem: FC<IGenerationItemProps> = ({
             className={cn(!isTop && 'rounded-br-xl border-l-2 border-primary-400', 'p-4')}
             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} />
             {messageId && (
               <div className='flex items-center justify-between mt-3'>

+ 24 - 0
web/app/components/base/icons/assets/public/files/csv.svg

@@ -0,0 +1,24 @@
+<svg width="32" height="34" viewBox="0 0 32 34" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="File Icons/csv">
+<g id="sharp" filter="url(#filter0_d_6816_769)">
+<path d="M4 7.73398C4 5.49377 4 4.37367 4.43597 3.51802C4.81947 2.76537 5.43139 2.15345 6.18404 1.76996C7.03969 1.33398 8.15979 1.33398 10.4 1.33398H18.6667L28 10.6673V24.2673C28 26.5075 28 27.6276 27.564 28.4833C27.1805 29.2359 26.5686 29.8478 25.816 30.2313C24.9603 30.6673 23.8402 30.6673 21.6 30.6673H10.4C8.15979 30.6673 7.03969 30.6673 6.18404 30.2313C5.43139 29.8478 4.81947 29.2359 4.43597 28.4833C4 27.6276 4 26.5075 4 24.2673V7.73398Z" fill="#169951"/>
+</g>
+<g id="CSV" opacity="0.96">
+<path d="M13.0846 21.8908C12.8419 23.3562 11.8246 24.0562 10.5646 24.0562C9.78992 24.0562 9.20192 23.7948 8.71659 23.3095C8.01659 22.6095 8.04459 21.6762 8.04459 20.6775C8.04459 19.6788 8.01659 18.7455 8.71659 18.0455C9.20192 17.5602 9.78992 17.2988 10.5646 17.2988C11.8246 17.2988 12.8419 17.9988 13.0846 19.4642H11.4233C11.3206 19.0908 11.1153 18.7548 10.5739 18.7548C10.2753 18.7548 10.0513 18.8762 9.92992 19.0348C9.78059 19.2308 9.67792 19.4642 9.67792 20.6775C9.67792 21.8908 9.78059 22.1242 9.92992 22.3202C10.0513 22.4788 10.2753 22.6002 10.5739 22.6002C11.1153 22.6002 11.3206 22.2642 11.4233 21.8908H13.0846Z" fill="white"/>
+<path d="M18.4081 21.9655C18.4081 23.3188 17.2414 24.0562 15.8414 24.0562C14.8241 24.0562 13.9934 23.8695 13.3214 23.1788L14.3668 22.1335C14.7121 22.4788 15.3188 22.6002 15.8508 22.6002C16.4948 22.6002 16.8028 22.3855 16.8028 22.0028C16.8028 21.8442 16.7654 21.7135 16.6721 21.6108C16.5881 21.5268 16.4481 21.4615 16.2334 21.4335L15.4308 21.3215C14.8428 21.2375 14.3948 21.0415 14.0961 20.7335C13.7881 20.4162 13.6388 19.9682 13.6388 19.3988C13.6388 18.1855 14.5534 17.2988 16.0654 17.2988C17.0174 17.2988 17.7361 17.5228 18.3054 18.0922L17.2788 19.1188C16.8588 18.6988 16.3081 18.7268 16.0188 18.7268C15.4494 18.7268 15.2161 19.0535 15.2161 19.3428C15.2161 19.4268 15.2441 19.5482 15.3468 19.6508C15.4308 19.7348 15.5708 19.8188 15.8041 19.8468L16.6068 19.9588C17.2041 20.0428 17.6334 20.2295 17.9134 20.5095C18.2681 20.8548 18.4081 21.3495 18.4081 21.9655Z" fill="white"/>
+<path d="M24.4166 17.3548L22.214 24.0002H21.0006L18.8073 17.3548H20.4966L21.6166 21.0695L22.718 17.3548H24.4166Z" fill="white"/>
+</g>
+<path id="bevel" opacity="0.5" d="M18.6667 1.33398L28.0001 10.6673H21.3334C19.8607 10.6673 18.6667 9.47341 18.6667 8.00065V1.33398Z" fill="white"/>
+</g>
+<defs>
+<filter id="filter0_d_6816_769" x="2" y="0.333984" width="28" height="33.334" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
+<feFlood flood-opacity="0" result="BackgroundImageFix"/>
+<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
+<feOffset dy="1"/>
+<feGaussianBlur stdDeviation="1"/>
+<feColorMatrix type="matrix" values="0 0 0 0 0.0627451 0 0 0 0 0.0941176 0 0 0 0 0.156863 0 0 0 0.05 0"/>
+<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_6816_769"/>
+<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_6816_769" result="shape"/>
+</filter>
+</defs>
+</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>

+ 181 - 0
web/app/components/base/icons/src/public/files/Csv.json

@@ -0,0 +1,181 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "32",
+			"height": "34",
+			"viewBox": "0 0 32 34",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "g",
+				"attributes": {
+					"id": "File Icons/csv"
+				},
+				"children": [
+					{
+						"type": "element",
+						"name": "g",
+						"attributes": {
+							"id": "sharp",
+							"filter": "url(#filter0_d_6816_769)"
+						},
+						"children": [
+							{
+								"type": "element",
+								"name": "path",
+								"attributes": {
+									"d": "M4 7.73398C4 5.49377 4 4.37367 4.43597 3.51802C4.81947 2.76537 5.43139 2.15345 6.18404 1.76996C7.03969 1.33398 8.15979 1.33398 10.4 1.33398H18.6667L28 10.6673V24.2673C28 26.5075 28 27.6276 27.564 28.4833C27.1805 29.2359 26.5686 29.8478 25.816 30.2313C24.9603 30.6673 23.8402 30.6673 21.6 30.6673H10.4C8.15979 30.6673 7.03969 30.6673 6.18404 30.2313C5.43139 29.8478 4.81947 29.2359 4.43597 28.4833C4 27.6276 4 26.5075 4 24.2673V7.73398Z",
+									"fill": "#169951"
+								},
+								"children": []
+							}
+						]
+					},
+					{
+						"type": "element",
+						"name": "g",
+						"attributes": {
+							"id": "CSV",
+							"opacity": "0.96"
+						},
+						"children": [
+							{
+								"type": "element",
+								"name": "path",
+								"attributes": {
+									"d": "M13.0846 21.8908C12.8419 23.3562 11.8246 24.0562 10.5646 24.0562C9.78992 24.0562 9.20192 23.7948 8.71659 23.3095C8.01659 22.6095 8.04459 21.6762 8.04459 20.6775C8.04459 19.6788 8.01659 18.7455 8.71659 18.0455C9.20192 17.5602 9.78992 17.2988 10.5646 17.2988C11.8246 17.2988 12.8419 17.9988 13.0846 19.4642H11.4233C11.3206 19.0908 11.1153 18.7548 10.5739 18.7548C10.2753 18.7548 10.0513 18.8762 9.92992 19.0348C9.78059 19.2308 9.67792 19.4642 9.67792 20.6775C9.67792 21.8908 9.78059 22.1242 9.92992 22.3202C10.0513 22.4788 10.2753 22.6002 10.5739 22.6002C11.1153 22.6002 11.3206 22.2642 11.4233 21.8908H13.0846Z",
+									"fill": "white"
+								},
+								"children": []
+							},
+							{
+								"type": "element",
+								"name": "path",
+								"attributes": {
+									"d": "M18.4081 21.9655C18.4081 23.3188 17.2414 24.0562 15.8414 24.0562C14.8241 24.0562 13.9934 23.8695 13.3214 23.1788L14.3668 22.1335C14.7121 22.4788 15.3188 22.6002 15.8508 22.6002C16.4948 22.6002 16.8028 22.3855 16.8028 22.0028C16.8028 21.8442 16.7654 21.7135 16.6721 21.6108C16.5881 21.5268 16.4481 21.4615 16.2334 21.4335L15.4308 21.3215C14.8428 21.2375 14.3948 21.0415 14.0961 20.7335C13.7881 20.4162 13.6388 19.9682 13.6388 19.3988C13.6388 18.1855 14.5534 17.2988 16.0654 17.2988C17.0174 17.2988 17.7361 17.5228 18.3054 18.0922L17.2788 19.1188C16.8588 18.6988 16.3081 18.7268 16.0188 18.7268C15.4494 18.7268 15.2161 19.0535 15.2161 19.3428C15.2161 19.4268 15.2441 19.5482 15.3468 19.6508C15.4308 19.7348 15.5708 19.8188 15.8041 19.8468L16.6068 19.9588C17.2041 20.0428 17.6334 20.2295 17.9134 20.5095C18.2681 20.8548 18.4081 21.3495 18.4081 21.9655Z",
+									"fill": "white"
+								},
+								"children": []
+							},
+							{
+								"type": "element",
+								"name": "path",
+								"attributes": {
+									"d": "M24.4166 17.3548L22.214 24.0002H21.0006L18.8073 17.3548H20.4966L21.6166 21.0695L22.718 17.3548H24.4166Z",
+									"fill": "white"
+								},
+								"children": []
+							}
+						]
+					},
+					{
+						"type": "element",
+						"name": "path",
+						"attributes": {
+							"id": "bevel",
+							"opacity": "0.5",
+							"d": "M18.6667 1.33398L28.0001 10.6673H21.3334C19.8607 10.6673 18.6667 9.47341 18.6667 8.00065V1.33398Z",
+							"fill": "white"
+						},
+						"children": []
+					}
+				]
+			},
+			{
+				"type": "element",
+				"name": "defs",
+				"attributes": {},
+				"children": [
+					{
+						"type": "element",
+						"name": "filter",
+						"attributes": {
+							"id": "filter0_d_6816_769",
+							"x": "2",
+							"y": "0.333984",
+							"width": "28",
+							"height": "33.334",
+							"filterUnits": "userSpaceOnUse",
+							"color-interpolation-filters": "sRGB"
+						},
+						"children": [
+							{
+								"type": "element",
+								"name": "feFlood",
+								"attributes": {
+									"flood-opacity": "0",
+									"result": "BackgroundImageFix"
+								},
+								"children": []
+							},
+							{
+								"type": "element",
+								"name": "feColorMatrix",
+								"attributes": {
+									"in": "SourceAlpha",
+									"type": "matrix",
+									"values": "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0",
+									"result": "hardAlpha"
+								},
+								"children": []
+							},
+							{
+								"type": "element",
+								"name": "feOffset",
+								"attributes": {
+									"dy": "1"
+								},
+								"children": []
+							},
+							{
+								"type": "element",
+								"name": "feGaussianBlur",
+								"attributes": {
+									"stdDeviation": "1"
+								},
+								"children": []
+							},
+							{
+								"type": "element",
+								"name": "feColorMatrix",
+								"attributes": {
+									"type": "matrix",
+									"values": "0 0 0 0 0.0627451 0 0 0 0 0.0941176 0 0 0 0 0.156863 0 0 0 0.05 0"
+								},
+								"children": []
+							},
+							{
+								"type": "element",
+								"name": "feBlend",
+								"attributes": {
+									"mode": "normal",
+									"in2": "BackgroundImageFix",
+									"result": "effect1_dropShadow_6816_769"
+								},
+								"children": []
+							},
+							{
+								"type": "element",
+								"name": "feBlend",
+								"attributes": {
+									"mode": "normal",
+									"in": "SourceGraphic",
+									"in2": "effect1_dropShadow_6816_769",
+									"result": "shape"
+								},
+								"children": []
+							}
+						]
+					}
+				]
+			}
+		]
+	},
+	"name": "Csv"
+}

+ 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'

+ 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'
-import React, { FC } from 'react'
+import type { FC } from 'react'
+import React from 'react'
 import cn from 'classnames'
 
 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
   onChange: (value: string) => void
 }
@@ -17,20 +21,26 @@ export interface ITabHeaderProps {
 const TabHeader: FC<ITabHeaderProps> = ({
   items,
   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 (
-    <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>
   )
 }

+ 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 { useTranslation } from 'react-i18next'
 import cn from 'classnames'
-import { useBoolean, useClickAway } from 'ahooks'
+import { useBoolean, useClickAway, useGetState } from 'ahooks'
 import { XMarkIcon } from '@heroicons/react/24/outline'
 import TabHeader from '../../base/tab-header'
 import Button from '../../base/button'
 import s from './style.module.css'
+import RunBatch from './run-batch'
 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 { MoreLikeThisConfig, PromptConfig, SavedMessage } from '@/models/debug'
-import Toast from '@/app/components/base/toast'
 import AppIcon from '@/app/components/base/app-icon'
-import type { Feedbacktype } from '@/app/components/app/chat'
 import { changeLanguage } from '@/i18n/i18next-config'
 import Loading from '@/app/components/base/loading'
 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 type { InstalledApp } from '@/models/explore'
 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 = {
   isInstalledApp?: boolean
@@ -35,134 +51,209 @@ const TextGeneration: FC<IMainProps> = ({
   isInstalledApp = false,
   installedAppInfo,
 }) => {
+  const { notify } = Toast
+
   const { t } = useTranslation()
   const media = useBreakpoints()
   const isPC = media === MediaType.pc
   const isTablet = media === MediaType.tablet
-  const isMoble = media === MediaType.mobile
+  const isMobile = media === MediaType.mobile
 
   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 [query, setQuery] = useState('') // run once query content
   const [appId, setAppId] = useState<string>('')
   const [siteInfo, setSiteInfo] = useState<SiteInfo | null>(null)
   const [promptConfig, setPromptConfig] = useState<PromptConfig | 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 fetchSavedMessage = async () => {
     const res: any = await doFetchSavedMessage(isInstalledApp, installedAppInfo?.id)
     setSavedMessages(res.data)
   }
-
   useEffect(() => {
     fetchSavedMessage()
   }, [])
-
   const handleSaveMessage = async (messageId: string) => {
     await saveMessage(messageId, isInstalledApp, installedAppInfo?.id)
     notify({ type: 'success', message: t('common.api.saved') })
     fetchSavedMessage()
   }
-
   const handleRemoveSavedMessage = async (messageId: string) => {
     await removeMessage(messageId, isInstalledApp, installedAppInfo?.id)
     notify({ type: 'success', message: t('common.api.remove') })
     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
 
-      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 !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
     }
 
-    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
     }
+    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 = () => {
@@ -209,14 +300,37 @@ const TextGeneration: FC<IMainProps> = ({
     hideResSidebar()
   }, 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
       ref={resRef}
       className={
         cn(
           'flex flex-col h-full shrink-0',
           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 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>
@@ -309,9 +402,11 @@ const TextGeneration: FC<IMainProps> = ({
           <TabHeader
             items={[
               { id: 'create', name: t('share.generation.tabs.create') },
+              { id: 'batch', name: t('share.generation.tabs.batch') },
               {
                 id: 'saved',
                 name: t('share.generation.tabs.saved'),
+                isRight: true,
                 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'>
@@ -325,8 +420,8 @@ const TextGeneration: FC<IMainProps> = ({
             onChange={setCurrTab}
           />
           <div className='grow h-20 overflow-y-auto'>
-            {currTab === 'create' && (
-              <ConfigScence
+            <div className={cn(currTab === 'create' ? 'block' : 'hidden')}>
+              <RunOnce
                 siteInfo={siteInfo}
                 inputs={inputs}
                 onInputsChange={setInputs}
@@ -335,7 +430,13 @@ const TextGeneration: FC<IMainProps> = ({
                 onQueryChange={setQuery}
                 onSend={handleSend}
               />
-            )}
+            </div>
+            <div className={cn(isInBatchTab ? 'block' : 'hidden')}>
+              <RunBatch
+                vars={promptConfig.prompt_variables}
+                onSend={handleRunBatch}
+              />
+            </div>
 
             {currTab === 'saved' && (
               <SavedItems
@@ -371,7 +472,7 @@ const TextGeneration: FC<IMainProps> = ({
         {/* Result */}
         {isPC && (
           <div className='grow h-full'>
-            {renderRes}
+            {renderResWrap}
           </div>
         )}
 
@@ -382,7 +483,7 @@ const TextGeneration: FC<IMainProps> = ({
               background: 'rgba(35, 56, 118, 0.2)',
             }}
           >
-            {renderRes}
+            {renderResWrap}
           </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 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 = {
-  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> = ({
-  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 (
-    <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>
   )
 }

+ 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 → web/app/components/share/text-generation/run-once/index.tsx

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

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

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

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

@@ -30,7 +30,8 @@ const translation = {
   },
   generation: {
     tabs: {
-      create: 'Create',
+      create: 'Run Once',
+      batch: 'Run Batch',
       saved: 'Saved',
     },
     savedNoData: {
@@ -41,10 +42,22 @@ const translation = {
     title: 'AI Completion',
     queryTitle: 'Query content',
     queryPlaceholder: 'Write your query content...',
-    run: 'RUN',
+    run: 'Execute',
     copy: 'Copy',
     resultTitle: 'AI Completion',
     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: {
     tabs: {
-      create: '创建',
+      create: '运行一次',
+      batch: '批量运行',
       saved: '已保存',
     },
     savedNoData: {
@@ -41,6 +42,18 @@ const translation = {
     copy: '拷贝',
     resultTitle: '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-infinite-scroll-component": "^6.1.0",
     "react-markdown": "^8.0.6",
+    "react-papaparse": "^4.1.0",
     "react-slider": "^2.0.4",
     "react-sortablejs": "^6.1.4",
     "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) => {
         if (moreInfo.errorMessage) {
+          onError?.(moreInfo.errorMessage)
           Toast.notify({ type: 'error', message: moreInfo.errorMessage })
           return
         }
         onData?.(str, isFirstMessage, moreInfo)
       }, onCompleted)
     }).catch((e) => {
-      // debugger
       Toast.notify({ type: 'error', message: e })
       onError?.(e)
     })