From 11baff6740c3db66cbfb24b16d6ca09310f9e201 Mon Sep 17 00:00:00 2001 From: Joel Date: Fri, 7 Jul 2023 10:35:05 +0800 Subject: [PATCH] feat: text generation application support run batch (#529) --- .../app/text-generate/item/index.tsx | 22 +- .../base/icons/assets/public/files/csv.svg | 24 ++ .../vender/solid/general/download-02.svg | 3 + .../base/icons/src/public/files/Csv.json | 181 +++++++++ .../base/icons/src/public/files/Csv.tsx | 14 + .../base/icons/src/public/files/index.ts | 1 + .../src/vender/solid/general/Download02.json | 29 ++ .../src/vender/solid/general/Download02.tsx | 14 + .../icons/src/vender/solid/general/index.ts | 1 + web/app/components/base/tab-header/index.tsx | 48 ++- .../share/text-generation/index.tsx | 357 +++++++++++------- .../share/text-generation/result/content.tsx | 34 ++ .../share/text-generation/result/index.tsx | 219 +++++++++-- .../run-batch/csv-download/index.tsx | 70 ++++ .../run-batch/csv-reader/index.tsx | 70 ++++ .../run-batch/csv-reader/style.module.css | 11 + .../share/text-generation/run-batch/index.tsx | 53 +++ .../{config-scence => run-once}/index.tsx | 8 +- web/i18n/lang/app-debug.en.ts | 2 + web/i18n/lang/app-debug.zh.ts | 1 + web/i18n/lang/share-app.en.ts | 17 +- web/i18n/lang/share-app.zh.ts | 15 +- web/package.json | 1 + web/service/base.ts | 2 +- 24 files changed, 1017 insertions(+), 180 deletions(-) create mode 100644 web/app/components/base/icons/assets/public/files/csv.svg create mode 100644 web/app/components/base/icons/assets/vender/solid/general/download-02.svg create mode 100644 web/app/components/base/icons/src/public/files/Csv.json create mode 100644 web/app/components/base/icons/src/public/files/Csv.tsx create mode 100644 web/app/components/base/icons/src/vender/solid/general/Download02.json create mode 100644 web/app/components/base/icons/src/vender/solid/general/Download02.tsx create mode 100644 web/app/components/base/icons/src/vender/solid/general/index.ts create mode 100644 web/app/components/share/text-generation/result/content.tsx create mode 100644 web/app/components/share/text-generation/run-batch/csv-download/index.tsx create mode 100644 web/app/components/share/text-generation/run-batch/csv-reader/index.tsx create mode 100644 web/app/components/share/text-generation/run-batch/csv-reader/style.module.css create mode 100644 web/app/components/share/text-generation/run-batch/index.tsx rename web/app/components/share/text-generation/{config-scence => run-once}/index.tsx (95%) diff --git a/web/app/components/app/text-generate/item/index.tsx b/web/app/components/app/text-generate/item/index.tsx index f9e1c2e813..faabf16d60 100644 --- a/web/app/components/app/text-generate/item/index.tsx +++ b/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 = ({ isMobile, isInstalledApp, installedAppId, + taskId, + controlClearMoreLikeThis, }) => { const { t } = useTranslation() const isTop = depth === 1 @@ -112,6 +117,7 @@ const GenerationItem: FC = ({ isMobile, isInstalledApp, installedAppId, + controlClearMoreLikeThis, } const handleMoreLikeThis = async () => { @@ -138,6 +144,14 @@ const GenerationItem: FC = ({ return res })() + + useEffect(() => { + if (controlClearMoreLikeThis) { + setChildMessageId(null) + setCompletionRes('') + } + }, [controlClearMoreLikeThis]) + return (
= ({ className={cn(!isTop && 'rounded-br-xl border-l-2 border-primary-400', 'p-4')} style={mainStyle} > + {(isTop && taskId) && ( +
+ + {taskId} +
) + } {messageId && (
diff --git a/web/app/components/base/icons/assets/public/files/csv.svg b/web/app/components/base/icons/assets/public/files/csv.svg new file mode 100644 index 0000000000..b108404cc1 --- /dev/null +++ b/web/app/components/base/icons/assets/public/files/csv.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/app/components/base/icons/assets/vender/solid/general/download-02.svg b/web/app/components/base/icons/assets/vender/solid/general/download-02.svg new file mode 100644 index 0000000000..0ad6d8b1fb --- /dev/null +++ b/web/app/components/base/icons/assets/vender/solid/general/download-02.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/components/base/icons/src/public/files/Csv.json b/web/app/components/base/icons/src/public/files/Csv.json new file mode 100644 index 0000000000..d4d2bd9f3e --- /dev/null +++ b/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" +} \ No newline at end of file diff --git a/web/app/components/base/icons/src/public/files/Csv.tsx b/web/app/components/base/icons/src/public/files/Csv.tsx new file mode 100644 index 0000000000..c47340cccb --- /dev/null +++ b/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, Omit>(( + props, + ref, +) => ) + +export default Icon diff --git a/web/app/components/base/icons/src/public/files/index.ts b/web/app/components/base/icons/src/public/files/index.ts index b8946e599d..028c8ca4b0 100644 --- a/web/app/components/base/icons/src/public/files/index.ts +++ b/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' diff --git a/web/app/components/base/icons/src/vender/solid/general/Download02.json b/web/app/components/base/icons/src/vender/solid/general/Download02.json new file mode 100644 index 0000000000..5854e64301 --- /dev/null +++ b/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" +} \ No newline at end of file diff --git a/web/app/components/base/icons/src/vender/solid/general/Download02.tsx b/web/app/components/base/icons/src/vender/solid/general/Download02.tsx new file mode 100644 index 0000000000..c51ff96de9 --- /dev/null +++ b/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, Omit>(( + props, + ref, +) => ) + +export default Icon diff --git a/web/app/components/base/icons/src/vender/solid/general/index.ts b/web/app/components/base/icons/src/vender/solid/general/index.ts new file mode 100644 index 0000000000..d056eef8af --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/general/index.ts @@ -0,0 +1 @@ +export { default as Download02 } from './Download02' diff --git a/web/app/components/base/tab-header/index.tsx b/web/app/components/base/tab-header/index.tsx index aedc3816e4..6ae5b69738 100644 --- a/web/app/components/base/tab-header/index.tsx +++ b/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 = ({ items, value, - onChange + onChange, }) => { + const renderItem = ({ id, name, extra }: Item) => ( +
onChange(id)} + > +
{name}
+ {extra || ''} +
+ ) return ( -
- {items.map(({ id, name, extra }) => ( -
onChange(id)} - > -
{name}
- {extra ? extra : ''} -
- ))} +
+
+ {items.filter(item => !item.isRight).map(renderItem)} +
+
+ {items.filter(item => item.isRight).map(renderItem)} +
) } diff --git a/web/app/components/share/text-generation/index.tsx b/web/app/components/share/text-generation/index.tsx index 3e5dd3ef46..5b2a077dcc 100644 --- a/web/app/components/share/text-generation/index.tsx +++ b/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 + query: string +} + +type Task = { + id: number + status: TaskStatus + params: TaskParam +} export type IMainProps = { isInstalledApp?: boolean @@ -35,134 +51,209 @@ const TextGeneration: FC = ({ 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('create') - + // Notice this situation isCallBatchAPI but not in batch tab + const [isCallBatchAPI, setIsCallBatchAPI] = useState(false) + const isInBatchTab = currTab === 'batch' const [inputs, setInputs] = useState>({}) + const [query, setQuery] = useState('') // run once query content const [appId, setAppId] = useState('') const [siteInfo, setSiteInfo] = useState(null) const [promptConfig, setPromptConfig] = useState(null) const [moreLikeThisConfig, setMoreLikeThisConfig] = useState(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(null) - const [feedback, setFeedback] = useState({ - 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([]) - 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([]) + 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 + + let payloadData = data.slice(1) + if (payloadData.length === 0) { + notify({ type: 'error', message: t('share.generation.errorMsg.atLeastOne') }) + return false + } + + // check middle empty line + const allEmptyLineIndexes = payloadData.filter(item => item.every(i => i === '')).map(item => payloadData.indexOf(item)) + if (allEmptyLineIndexes.length > 0) { + let hasMiddleEmptyLine = false + let startIndex = allEmptyLineIndexes[0] - 1 + allEmptyLineIndexes.forEach((index) => { + if (hasMiddleEmptyLine) + return + + if (startIndex + 1 !== index) { + hasMiddleEmptyLine = true + return + } + startIndex++ + }) + + if (hasMiddleEmptyLine) { + notify({ type: 'error', message: t('share.generation.errorMsg.emptyLine', { rowIndex: startIndex + 2 }) }) + return false + } + } + + // check row format + payloadData = payloadData.filter(item => !item.every(i => i === '')) + // after remove empty rows in the end, checked again + if (payloadData.length === 0) { + notify({ type: 'error', message: t('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 + } + }) + + 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 = {} + 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()) } - 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, + 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 = ({ hideResSidebar() }, resRef) - const renderRes = ( + const renderRes = (task?: Task) => () + + const renderBatchRes = () => { + return (showTaskList.map(task => renderRes(task))) + } + + const renderResWrap = (
<> @@ -236,33 +350,12 @@ const TextGeneration: FC = ({
- {(isResponsing && !completionRes) - ? ( -
- -
) - : ( - <> - {isNoData - ? - : ( - - ) - } - - )} + {!isCallBatchAPI ? renderRes() : renderBatchRes()} + {!noPendingTask && ( +
+ +
+ )}
@@ -309,9 +402,11 @@ const TextGeneration: FC = ({ 0 ? (
@@ -325,8 +420,8 @@ const TextGeneration: FC = ({ onChange={setCurrTab} />
- {currTab === 'create' && ( - + = ({ onQueryChange={setQuery} onSend={handleSend} /> - )} +
+
+ +
{currTab === 'saved' && ( = ({ {/* Result */} {isPC && (
- {renderRes} + {renderResWrap}
)} @@ -382,7 +483,7 @@ const TextGeneration: FC = ({ background: 'rgba(35, 56, 118, 0.2)', }} > - {renderRes} + {renderResWrap}
)}
diff --git a/web/app/components/share/text-generation/result/content.tsx b/web/app/components/share/text-generation/result/content.tsx new file mode 100644 index 0000000000..227c583bdd --- /dev/null +++ b/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 = ({ + content, + showFeedback, + feedback, + onFeedback, +}) => { + return ( +
+
+
+
+ ) +} +export default React.memo(Result) diff --git a/web/app/components/share/text-generation/result/index.tsx b/web/app/components/share/text-generation/result/index.tsx index 2e5ecf4af1..3a19f83e91 100644 --- a/web/app/components/share/text-generation/result/index.tsx +++ b/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 + query: string + controlSend?: number + controlStopResponding?: number + onShowRes: () => void + handleSaveMessage: (messageId: string) => void + taskId?: number + onCompleted: (taskId?: number, success?: boolean) => void } + const Result: FC = ({ - 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(null) + const [feedback, setFeedback] = useState({ + 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 = () => ( + + ) + return ( -
-
-
+
+ {!isCallBatchAPI && ( + (isResponsing && !completionRes) + ? ( +
+ +
) + : ( + <> + {isNoData + ? + : renderTextGenerationRes() + } + + ) + )} + {isCallBatchAPI && ( +
+ {renderTextGenerationRes()} +
+ )}
) } diff --git a/web/app/components/share/text-generation/run-batch/csv-download/index.tsx b/web/app/components/share/text-generation/run-batch/csv-download/index.tsx new file mode 100644 index 0000000000..77284c4020 --- /dev/null +++ b/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 = ({ + vars, +}) => { + const { t } = useTranslation() + const { CSVDownloader, Type } = useCSVDownloader() + const addQueryContentVars = [...vars, { name: t('share.generation.queryTitle') }] + const template = (() => { + const res: Record = {} + addQueryContentVars.forEach((item) => { + res[item.name] = '' + }) + return res + })() + + return ( +
+
{t('share.generation.csvStructureTitle')}
+
+ + + + {addQueryContentVars.map((item, i) => ( + + ))} + + + + + {addQueryContentVars.map((item, i) => ( + + ))} + + +
{item.name}
{item.name} {t('share.generation.field')}
+
+ +
+ + {t('share.generation.downloadTemplate')} +
+
+
+ + ) +} +export default React.memo(CSVDownload) diff --git a/web/app/components/share/text-generation/run-batch/csv-reader/index.tsx b/web/app/components/share/text-generation/run-batch/csv-reader/index.tsx new file mode 100644 index 0000000000..80432acf31 --- /dev/null +++ b/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 = ({ + onParsed, +}) => { + const { t } = useTranslation() + const { CSVReader } = useCSVReader() + const [zoneHover, setZoneHover] = useState(false) + return ( + { + onParsed(results.data) + setZoneHover(false) + }} + onDragOver={(event: DragEvent) => { + event.preventDefault() + setZoneHover(true) + }} + onDragLeave={(event: DragEvent) => { + event.preventDefault() + setZoneHover(false) + }} + > + {({ + getRootProps, + acceptedFile, + }: any) => ( + <> +
+ { + acceptedFile + ? ( +
+ +
+ {acceptedFile.name.replace(/.csv$/, '')} + .csv +
+
+ ) + : ( +
+ +
{t('share.generation.csvUploadTitle')}{t('share.generation.browse')}
+
+ )} +
+ + )} +
+ ) +} + +export default React.memo(CSVReader) diff --git a/web/app/components/share/text-generation/run-batch/csv-reader/style.module.css b/web/app/components/share/text-generation/run-batch/csv-reader/style.module.css new file mode 100644 index 0000000000..ff0b6aa157 --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/web/app/components/share/text-generation/run-batch/index.tsx b/web/app/components/share/text-generation/run-batch/index.tsx new file mode 100644 index 0000000000..78645bc87d --- /dev/null +++ b/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 = ({ + vars, + onSend, +}) => { + const { t } = useTranslation() + + const [csvData, setCsvData] = React.useState([]) + const [isParsed, setIsParsed] = React.useState(false) + const handleParsed = (data: string[][]) => { + setCsvData(data) + // console.log(data) + setIsParsed(true) + } + + const handleSend = () => { + onSend(csvData) + } + return ( +
+ + +
+
+ +
+
+ ) +} +export default React.memo(RunBatch) diff --git a/web/app/components/share/text-generation/config-scence/index.tsx b/web/app/components/share/text-generation/run-once/index.tsx similarity index 95% rename from web/app/components/share/text-generation/config-scence/index.tsx rename to web/app/components/share/text-generation/run-once/index.tsx index 270d4cea83..38df8ad489 100644 --- a/web/app/components/share/text-generation/config-scence/index.tsx +++ b/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 @@ -19,7 +19,7 @@ export type IConfigSenceProps = { onQueryChange: (query: string) => void onSend: () => void } -const ConfigSence: FC = ({ +const RunOnce: FC = ({ promptConfig, inputs, onInputsChange, @@ -85,7 +85,7 @@ const ConfigSence: FC = ({
) } -export default React.memo(ConfigSence) +export default React.memo(RunOnce) diff --git a/web/i18n/lang/app-debug.en.ts b/web/i18n/lang/app-debug.en.ts index 484c58a7cc..f5df2b390a 100644 --- a/web/i18n/lang/app-debug.en.ts +++ b/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', diff --git a/web/i18n/lang/app-debug.zh.ts b/web/i18n/lang/app-debug.zh.ts index 910e057e80..7b8f725ae0 100644 --- a/web/i18n/lang/app-debug.zh.ts +++ b/web/i18n/lang/app-debug.zh.ts @@ -84,6 +84,7 @@ const translation = { valueOfVarRequired: '变量值必填', queryRequired: '主要文本必填', waitForResponse: '请等待上条信息响应完成', + waitForBatchResponse: '请等待批量任务完成', }, chatSubTitle: '对话前提示词', completionSubTitle: '前缀提示词', diff --git a/web/i18n/lang/share-app.en.ts b/web/i18n/lang/share-app.en.ts index 9c030f9142..55974bcc87 100644 --- a/web/i18n/lang/share-app.en.ts +++ b/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.', + }, }, } diff --git a/web/i18n/lang/share-app.zh.ts b/web/i18n/lang/share-app.zh.ts index 69ef81863d..fcb108f610 100644 --- a/web/i18n/lang/share-app.zh.ts +++ b/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: '上传文件的内容不能少于一条', + }, }, } diff --git a/web/package.json b/web/package.json index c7cc921922..f0fdf8d665 100644 --- a/web/package.json +++ b/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", diff --git a/web/service/base.ts b/web/service/base.ts index 43f9b9207a..a6ea0c9981 100644 --- a/web/service/base.ts +++ b/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) })