From ec9eb03ae1ca926506471f4751f20c80e6833777 Mon Sep 17 00:00:00 2001 From: keting lu Date: Mon, 3 Mar 2025 14:37:30 +0800 Subject: [PATCH] chore: frontend --- web/.env.example | 2 + .../app/(appDetailLayout)/[appId]/layout.tsx | 6 +- .../(appDetailLayout)/[appId]/run/page.tsx | 12 + web/app/(commonLayout)/apps/AppCard.tsx | 8 + web/app/(commonLayout)/apps/Apps.tsx | 9 +- .../components/app/create-app-modal/index.tsx | 11 +- web/app/components/base/alert/constant.ts | 39 + web/app/components/base/alert/index.tsx | 319 ++++++++ web/app/components/base/line-chart/index.tsx | 239 ++++++ web/app/components/base/log/LogContent.tsx | 39 + web/app/components/base/topology/hook.ts | 98 +++ web/app/components/base/topology/index.css | 0 web/app/components/base/topology/index.tsx | 173 +++++ .../components/base/topology/loop-edges.tsx | 36 + .../components/base/topology/service-node.tsx | 36 + web/app/components/run/index.tsx | 41 ++ web/app/components/run/node/llm-outputs.tsx | 15 + web/app/components/run/node/node.tsx | 233 ++++++ .../run/text-generation/icons/star.svg | 5 + .../components/run/text-generation/index.tsx | 679 ++++++++++++++++++ .../run/text-generation/no-data/index.tsx | 26 + .../run/text-generation/result/content.tsx | 34 + .../run/text-generation/result/header.tsx | 113 +++ .../run/text-generation/result/index.tsx | 401 +++++++++++ .../run-batch/csv-download/index.tsx | 70 ++ .../run-batch/csv-reader/index.tsx | 70 ++ .../run-batch/csv-reader/style.module.css | 11 + .../run/text-generation/run-batch/index.tsx | 59 ++ .../run-batch/res-download/index.tsx | 41 ++ .../run/text-generation/run-once/index.tsx | 168 +++++ .../run/text-generation/style.module.css | 12 + web/app/components/run/utils.ts | 53 ++ .../workflow/apo/ai-node-recommend.tsx | 96 +++ web/app/components/workflow/apo/constant.ts | 16 + web/app/components/workflow/apo/context.tsx | 24 + .../components/workflow/apo/conversation.tsx | 44 ++ .../workflow/apo/recommend-tools.tsx | 68 ++ .../workflow/apo/selectTestFunction.ts | 28 + web/app/components/workflow/apo/store.ts | 36 + web/app/components/workflow/apo/types.ts | 8 + .../workflow/block-selector/apoTools.tsx | 79 ++ .../workflow/block-selector/index.tsx | 69 +- .../workflow/block-selector/tabs.tsx | 12 +- web/app/components/workflow/index.tsx | 15 +- .../components/workflow/operator/control.tsx | 3 +- .../components/workflow/operator/sider.tsx | 54 ++ .../components/workflow/run/data-display.tsx | 63 ++ web/app/components/workflow/run/node.tsx | 4 + .../components/workflow/run/result-panel.tsx | 10 +- .../components/workflow/run/tracing-panel.tsx | 29 +- web/app/components/workflow/tool-sider.tsx | 51 ++ web/app/layout.tsx | 3 + web/config/index.ts | 11 + web/i18n/de-DE/apo.ts | 3 + web/i18n/en-US/apo.ts | 11 + web/i18n/es-ES/apo.ts | 3 + web/i18n/fa-IR/apo.ts | 3 + web/i18n/fr-FR/apo.ts | 3 + web/i18n/hi-IN/apo.ts | 3 + web/i18n/i18next-config.ts | 1 + web/i18n/it-IT/apo.ts | 3 + web/i18n/ja-JP/apo.ts | 3 + web/i18n/ko-KR/apo.ts | 3 + web/i18n/pl-PL/apo.ts | 3 + web/i18n/pt-BR/apo.ts | 3 + web/i18n/ro-RO/apo.ts | 3 + web/i18n/ru-RU/apo.ts | 3 + web/i18n/sl-SI/apo.ts | 3 + web/i18n/th-TH/apo.ts | 3 + web/i18n/tr-TR/apo.ts | 3 + web/i18n/uk-UA/apo.ts | 3 + web/i18n/vi-VN/apo.ts | 3 + web/i18n/zh-Hans/apo.ts | 11 + web/i18n/zh-Hant/apo.ts | 3 + web/service/base.ts | 111 ++- web/service/share.ts | 35 + web/service/tools.ts | 9 + 77 files changed, 3941 insertions(+), 72 deletions(-) create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/run/page.tsx create mode 100644 web/app/components/base/alert/constant.ts create mode 100644 web/app/components/base/alert/index.tsx create mode 100644 web/app/components/base/line-chart/index.tsx create mode 100644 web/app/components/base/log/LogContent.tsx create mode 100644 web/app/components/base/topology/hook.ts create mode 100644 web/app/components/base/topology/index.css create mode 100644 web/app/components/base/topology/index.tsx create mode 100644 web/app/components/base/topology/loop-edges.tsx create mode 100644 web/app/components/base/topology/service-node.tsx create mode 100644 web/app/components/run/index.tsx create mode 100644 web/app/components/run/node/llm-outputs.tsx create mode 100644 web/app/components/run/node/node.tsx create mode 100644 web/app/components/run/text-generation/icons/star.svg create mode 100644 web/app/components/run/text-generation/index.tsx create mode 100644 web/app/components/run/text-generation/no-data/index.tsx create mode 100644 web/app/components/run/text-generation/result/content.tsx create mode 100644 web/app/components/run/text-generation/result/header.tsx create mode 100644 web/app/components/run/text-generation/result/index.tsx create mode 100644 web/app/components/run/text-generation/run-batch/csv-download/index.tsx create mode 100644 web/app/components/run/text-generation/run-batch/csv-reader/index.tsx create mode 100644 web/app/components/run/text-generation/run-batch/csv-reader/style.module.css create mode 100644 web/app/components/run/text-generation/run-batch/index.tsx create mode 100644 web/app/components/run/text-generation/run-batch/res-download/index.tsx create mode 100644 web/app/components/run/text-generation/run-once/index.tsx create mode 100644 web/app/components/run/text-generation/style.module.css create mode 100644 web/app/components/run/utils.ts create mode 100644 web/app/components/workflow/apo/ai-node-recommend.tsx create mode 100644 web/app/components/workflow/apo/constant.ts create mode 100644 web/app/components/workflow/apo/context.tsx create mode 100644 web/app/components/workflow/apo/conversation.tsx create mode 100644 web/app/components/workflow/apo/recommend-tools.tsx create mode 100644 web/app/components/workflow/apo/selectTestFunction.ts create mode 100644 web/app/components/workflow/apo/store.ts create mode 100644 web/app/components/workflow/apo/types.ts create mode 100644 web/app/components/workflow/block-selector/apoTools.tsx create mode 100644 web/app/components/workflow/operator/sider.tsx create mode 100644 web/app/components/workflow/run/data-display.tsx create mode 100644 web/app/components/workflow/tool-sider.tsx create mode 100644 web/i18n/de-DE/apo.ts create mode 100644 web/i18n/en-US/apo.ts create mode 100644 web/i18n/es-ES/apo.ts create mode 100644 web/i18n/fa-IR/apo.ts create mode 100644 web/i18n/fr-FR/apo.ts create mode 100644 web/i18n/hi-IN/apo.ts create mode 100644 web/i18n/it-IT/apo.ts create mode 100644 web/i18n/ja-JP/apo.ts create mode 100644 web/i18n/ko-KR/apo.ts create mode 100644 web/i18n/pl-PL/apo.ts create mode 100644 web/i18n/pt-BR/apo.ts create mode 100644 web/i18n/ro-RO/apo.ts create mode 100644 web/i18n/ru-RU/apo.ts create mode 100644 web/i18n/sl-SI/apo.ts create mode 100644 web/i18n/th-TH/apo.ts create mode 100644 web/i18n/tr-TR/apo.ts create mode 100644 web/i18n/uk-UA/apo.ts create mode 100644 web/i18n/vi-VN/apo.ts create mode 100644 web/i18n/zh-Hans/apo.ts create mode 100644 web/i18n/zh-Hant/apo.ts diff --git a/web/.env.example b/web/.env.example index 800dbb633e..82ef17c006 100644 --- a/web/.env.example +++ b/web/.env.example @@ -37,3 +37,5 @@ NEXT_PUBLIC_TOP_K_MAX_VALUE=10 # The maximum number of tokens for segmentation NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000 + +NEXT_PUBLIC_V1_API_PREFIX="http://localhost:5001/v1" \ No newline at end of file diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout.tsx index 3b43186196..2b60a52c9b 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout.tsx @@ -19,7 +19,6 @@ import { useContextSelector } from 'use-context-selector' import s from './style.module.css' import cn from '@/utils/classnames' import { useStore } from '@/app/components/app/store' -import AppSideBar from '@/app/components/app-sidebar' import type { NavIcon } from '@/app/components/app-sidebar/navLink' import { fetchAppDetail, fetchAppSSO } from '@/service/apps' import AppContext, { useAppContext } from '@/context/app-context' @@ -164,9 +163,10 @@ const AppDetailLayout: FC = (props) => { return (
- {appDetail && ( + {/* {appDetail && ( - )} + )} */} +
{children}
diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/run/page.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/run/page.tsx new file mode 100644 index 0000000000..e41d909143 --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/run/page.tsx @@ -0,0 +1,12 @@ +'use client' + +import WorkflowRunContainer from '@/app/components/run' + +const Page = () => { + return ( +
+ +
+ ) +} +export default Page diff --git a/web/app/(commonLayout)/apps/AppCard.tsx b/web/app/(commonLayout)/apps/AppCard.tsx index e7e1251897..e3ef016c85 100644 --- a/web/app/(commonLayout)/apps/AppCard.tsx +++ b/web/app/(commonLayout)/apps/AppCard.tsx @@ -31,6 +31,7 @@ import DSLExportConfirmModal from '@/app/components/workflow/dsl-export-confirm- import { fetchWorkflowDraft } from '@/service/workflow' import { fetchInstalledAppList } from '@/service/explore' import { AppTypeIcon } from '@/app/components/app/type-selector' +import Button from '@/app/components/base/button' export type AppCardProps = { app: App @@ -302,6 +303,13 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { {app.mode === 'completion' &&
{t('app.types.completion').toUpperCase()}
}
+
+ +
{ const anchorRef = useRef(null) const options = [ - { value: 'all', text: t('app.types.all'), icon: }, - { value: 'chat', text: t('app.types.chatbot'), icon: }, - { value: 'agent-chat', text: t('app.types.agent'), icon: }, + // { value: 'all', text: t('app.types.all'), icon: }, + // { value: 'chat', text: t('app.types.chatbot'), icon: }, + // { value: 'agent-chat', text: t('app.types.agent'), icon: }, { value: 'workflow', text: t('app.types.workflow'), icon: }, ] diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index c5bfc340f0..f3ae2771b0 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -23,7 +23,6 @@ import Input from '@/app/components/base/input' import Textarea from '@/app/components/base/textarea' import AppIcon from '@/app/components/base/app-icon' import AppsFull from '@/app/components/billing/apps-full-in-dialog' -import { BubbleTextMod, ChatBot, ListSparkle, Logic } from '@/app/components/base/icons/src/vender/solid/communication' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { getRedirection } from '@/utils/app-redirection' import FullScreenModal from '@/app/components/base/fullscreen-modal' @@ -41,7 +40,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps) const { notify } = useContext(ToastContext) const mutateApps = useContextSelector(AppsContext, state => state.mutateApps) - const [appMode, setAppMode] = useState('chat') + const [appMode, setAppMode] = useState('workflow') const [appIcon, setAppIcon] = useState({ type: 'emoji', icon: '🤖', background: '#FFEAD5' }) const [showAppIconPicker, setShowAppIconPicker] = useState(false) const [name, setName] = useState('') @@ -105,7 +104,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps) {t('app.newApp.chooseAppType')}
-
+ {/*
{t('app.newApp.forBeginners')}
@@ -141,13 +140,13 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps) setAppMode('completion') }} />
-
+
*/}
{t('app.newApp.forAdvanced')}
- } onClick={() => { setAppMode('advanced-chat') - }} /> + }} /> */} { + console.log(finalAnormalEvents, deltaAnormalEvents, selectedNodeInfo) + const [newAnormalList, setNewAnormalList] = useState([]) + const [updateAnormalList, setUpdateAnormalList] = useState([]) + const [resolvedAnormalList, setResolvedAnormalList] = useState([]) + const [allAnormalList, setAllAnormalList] = useState([]) + const [anormalStatus, setAnormalStatus] = useState('all') + const [anormalType, setAnormalType] = useState([1, 2, 3, 4, 5]) + const [key, setKey] = useState(0) + const [options, setOptions] = useState([]) + const [scroll, setScroll] = useState(false) + const [currentList, setCurrentList] = useState([]) + const listRef = useRef({}) + const rowHeights = useRef({}) + + const getList = (anormalStatus: AnormalStatus) => { + if (anormalStatus === 'startFiring') return newAnormalList + if (anormalStatus === 'updatedFiring') return updateAnormalList + if (anormalStatus === 'resolved') return resolvedAnormalList + return allAnormalList + } + + const itemData = getList(anormalStatus) + + const getSelectedList = (anormalStatus: AnormalStatus) => { + if (anormalStatus === 'startFiring') return setCurrentList(newAnormalList) + if (anormalStatus === 'updatedFiring') return setCurrentList(updateAnormalList) + if (anormalStatus === 'resolved') return setCurrentList(resolvedAnormalList) + return setCurrentList(allAnormalList) + } + + const anormalStatusOptions = [ + { + key: 'startFiring', + label: ( + + {'新增'} {getList('startFiring').length} + + ), + }, + { + key: 'updatedFiring', + label: ( + + {'重复'} {getList('updatedFiring').length} + + ), + }, + { + key: 'resolved', + label: ( + + {'已解决'} {getList('resolved').length} + + ), + }, + { + key: 'all', + label: ( + + {'当前遗留'} {getList('all').length} + + ), + }, + ] + + const useRowChanged = ({ index, setRowHeight }) => { + const rowRef = useRef({}) + + useEffect(() => { + if (rowRef.current) + setRowHeight(index, rowRef.current.clientHeight) + }, [rowRef]) + + return { + rowRef, + } + } + + const setRowHeight = (index, size) => { + listRef.current.resetAfterIndex(0) + rowHeights.current = { ...rowHeights.current, [index]: size } + } + + const getRowHeight = index => (rowHeights.current[index] || 160) + 20 + + const filterByAnormalType = (array) => { + return array.filter(element => + anormalType.some(item => element.anormalType === Number.parseInt(item, 10)), + ) + } + + const Row = ({ index, style, data }) => { + delete style.height + const { rowRef } = useRowChanged({ index, setRowHeight }) + return ( +
+
+
{data[index].anormalReason}
+
+ 应用名称: + {data[index].serviceName} +
+
+ 服务端点: + {data[index].endpoint} +
+
+ 时间: + {dayjs(data[index].timestamp).format('YYYY-MM-DD HH:mm:ss')} +
+
+ 详情: + {data[index].anormalMsg} +
+
+
+
+ {anormalTypesOptions[data[index].anormalType].label} +
+
+
+ ) + } + + const removeType = (str) => { + if (str.includes('类型')) + return str.replace('类型', '') + + return str + } + + useEffect(() => { + const observer = new ResizeObserver(() => { + // 每当视口变化时,更新 `key`,强制组件重新加载 + setKey(prevKey => prevKey + 1) + }) + + // 监听整个 `document.body` 的尺寸变化 + observer.observe(document.body) + + // 清理函数:移除观察器 + return () => observer.disconnect() + }, []) + + useEffect(() => { + getSelectedList(anormalStatus) + }, [anormalStatus, itemData]) + + useEffect(() => { + const config = anormalTypesOptions + .filter(item => Object.values(anormalTypes).includes(item.value)) + .filter(item => item.key !== 0) + .map((item) => { + return { + value: item.key, + name: removeType(item.label), + type: item.value, + } + }) + setOptions(config) + const selectItems = anormalTypesOptions + .filter(item => Object.values(anormalTypes).includes(item.value)) + .map((item) => { + return item.key + }) + setAnormalType(selectItems) + }, [anormalTypes]) + + useEffect(() => { + if (listRef.current && currentList.length) + listRef.current.scrollTo(0) // 滚动到顶部 + }, [currentList]) + + useEffect(() => { + const newAnormalList = [] + const updateAnormalList = [] + const resolvedAnormalList = [] + const allAnormalList = [] + + deltaAnormalEvents.forEach((event) => { + if ( + !selectedNodeInfo?.endpoint + || (selectedNodeInfo?.endpoint === event.endpoint + && selectedNodeInfo?.service === event.serviceName) + ) { + if (event.anormalStatus === 'startFiring') + newAnormalList.push(event) + else if (event.anormalStatus === 'updatedFiring') + updateAnormalList.push(event) + else + resolvedAnormalList.push(event) + } + }) + finalAnormalEvents.forEach((event) => { + if ( + !selectedNodeInfo?.endpoint + || (selectedNodeInfo?.endpoint === event.endpoint + && selectedNodeInfo?.service === event.serviceName) + ) + allAnormalList.push(event) + }) + + setNewAnormalList(filterByAnormalType(newAnormalList)) + setUpdateAnormalList(filterByAnormalType(updateAnormalList)) + setResolvedAnormalList(filterByAnormalType(resolvedAnormalList)) + setAllAnormalList(filterByAnormalType(allAnormalList)) + }, [deltaAnormalEvents, finalAnormalEvents, selectedNodeInfo, anormalType]) + return ( +
+
+ {/*
+ ({ label: item, value: item }))} + value={currentSource} + onChange={value => setCurrentSource(value)} + /> */} +
+ {/*
*/} + {logContents?.contents && } + + ) +} +export default LogContent diff --git a/web/app/components/base/topology/hook.ts b/web/app/components/base/topology/hook.ts new file mode 100644 index 0000000000..a9e89d07cb --- /dev/null +++ b/web/app/components/base/topology/hook.ts @@ -0,0 +1,98 @@ +function escapeId(id: string) { + return id.replace(/[^a-zA-Z0-9-_]/g, '_') +} +function contactServiceEndpoint(service: string, endpoint: string) { + return escapeId(`${service}-${endpoint}`) +} +export const usePrepareTopologyData = (data) => { + console.log(data) + if (!data) + return { nodes: [], edges: [] } + + const nodeSet = new Set() // 用于存储已添加节点的 ID + const edgeSet = new Set() // 用于存储已添加边的 ID + const current = data.current + + const nodes = [ + { + id: contactServiceEndpoint(current.service, current.endpoint), + data: { + label: current.service, + isTraced: current.isTraced, + service: current.service, + endpoint: current.endpoint, + }, + position: { x: 0, y: 0 }, + type: 'serviceNode', + }, + ] + nodeSet.add(contactServiceEndpoint(current.service, current.endpoint)) + const edges = [] + data.parents?.forEach((parent) => { + const nodeId = contactServiceEndpoint(parent.service, parent.endpoint) + if (!nodeSet.has(nodeId)) { + nodes.push({ + id: nodeId, + data: { + label: parent.service, + isTraced: parent.isTraced, + service: parent.service, + endpoint: parent.endpoint, + }, + position: { x: 0, y: 0 }, + type: 'serviceNode', + }) + nodeSet.add(nodeId) + } + const edgeId = `${nodeId}-${contactServiceEndpoint(current.service, current.endpoint)}` + if (!edgeSet.has(edgeId)) { + const targetId = contactServiceEndpoint(current.service, current.endpoint) + edges.push({ + id: edgeId, + source: nodeId, + target: targetId, + type: nodeId === targetId ? 'loop' : 'smart', + markerEnd: 'url(#arrowhead)', + }) + edgeSet.add(edgeId) + } + }) + data.childRelations?.forEach((child, index) => { + const nodeId = contactServiceEndpoint(child.service, child.endpoint) + if (!nodeSet.has(nodeId)) { + nodes.push({ + id: nodeId, + data: { + label: child.service, + isTraced: child.isTraced, + service: child.service, + endpoint: child.endpoint, + }, + position: { x: 0, y: 0 }, + type: 'serviceNode', + }) + nodeSet.add(nodeId) + } + + const edgeId + = `${contactServiceEndpoint(child.parentService, child.parentEndpoint)}-${nodeId}` + if (!edgeSet.has(edgeId)) { + const sourceId = contactServiceEndpoint(child.parentService, child.parentEndpoint) + edges.push({ + id: edgeId, + source: sourceId, + target: nodeId, + type: nodeId === sourceId ? 'loop' : 'smart', + }) + edgeSet.add(edgeId) + } + }) + const resultNodes = [...nodes] + const result = resultNodes.reduce((acc, curr) => { + acc[curr.id] = 1 + return acc + }, {}) + console.log(result) + // prepareDataForChartAndTopologyNode(result) + return { nodes: resultNodes, edges } +} diff --git a/web/app/components/base/topology/index.css b/web/app/components/base/topology/index.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/web/app/components/base/topology/index.tsx b/web/app/components/base/topology/index.tsx new file mode 100644 index 0000000000..7cee52ca8a --- /dev/null +++ b/web/app/components/base/topology/index.tsx @@ -0,0 +1,173 @@ +import ReactFlow, { + Background, + MarkerType, + ReactFlowProvider, + useEdgesState, + useNodesState, + useReactFlow, +} from 'reactflow' +import 'reactflow/dist/style.css' +import './index.css' +import ServiceNode from './service-node' +import CustomSelfLoopEdge from './loop-edges' +import { useEffect, useMemo, useRef } from 'react' +import { layout as dagreLayout, graphlib } from '@dagrejs/dagre' +const nodeWidth = 200 +const defaultNodeTypes = { + serviceNode: ServiceNode, + // moreNode: MoreNode, +} // 定义在组件外部 +const edgeTypes = { + // smart: SmartBezierEdge, // 或者使用 SmartBezierEdge 等 + loop: CustomSelfLoopEdge, +} +const LayoutFlow = (props) => { + const { data, nodeHeight = 60, nodeTypes = {} } = props + // 所有链路 + const reactFlowInstance = useRef(null) + // const [initialNodes, setInitialNodes] = useState([]) + // const [initialEdges, setInitialEdges] = useState([]) + const [nodes, setNodes, onNodesChange] = useNodesState([]) + const [edges, setEdges, onEdgesChange] = useEdgesState([]) + + const { fitView } = useReactFlow() + const markerEnd = { + type: MarkerType.ArrowClosed, + strokeWidth: 5, + width: 25, + height: 25, + color: '#6293ff', + } + const prepareData = () => { + const initialNodes = data?.nodes || [] + const initialEdges = [] + data.edges.forEach((edge) => { + initialEdges.push({ + ...edge, + markerEnd, + style: { + stroke: '#6293FF', + }, + }) + }) + return { initialNodes, initialEdges } + } + + const dagreGraph = new graphlib.Graph() + dagreGraph.setDefaultEdgeLabel(() => ({})) + + const getLayoutedElements = (nodes, edges) => { + dagreGraph.setGraph({ rankdir: 'LR', ranksep: 100, nodesep: 50 }) // 自上而下的布局 + + nodes.forEach((node) => { + dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight }) + }) + + edges.forEach((edge) => { + dagreGraph.setEdge(edge.source, edge.target) + }) + + dagreLayout(dagreGraph) + nodes.forEach((node) => { + const nodeWithPosition = dagreGraph.node(node.id) + node.targetPosition = 'left' + node.sourcePosition = 'right' + + node.position = { + x: nodeWithPosition.x - nodeWidth, + y: nodeWithPosition.y - nodeHeight / 2, + } + }) + + // Calculate offsets to center the graph + const xMin = Math.min(...nodes.map(node => node.position.x)) + const yMin = Math.min(...nodes.map(node => node.position.y)) + + nodes.forEach((node) => { + node.position.x -= xMin - nodeWidth / 2 + node.position.y -= yMin - nodeHeight / 2 + }) + edges.map((edge) => { + const sourceNode = nodes.find(node => node.id === edge.source) + const targetNode = nodes.find(node => node.id === edge.target) + if ( + sourceNode.position.x > targetNode.position.x + && Math.abs(sourceNode.position.y - targetNode.position.y) < nodeHeight + ) + edge.type = 'loop' + }) + return { nodes, edges } + } + + useEffect(() => { + console.log(data) + const { initialNodes, initialEdges } = prepareData() + const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements( + initialNodes, + initialEdges, + ) + setNodes([...layoutedNodes]) + setEdges([...layoutedEdges]) + requestAnimationFrame(() => { + if (reactFlowInstance.current) { + setTimeout(() => { + fitView({ + padding: layoutedNodes.length > 2 ? 0.1 : 0.2, + includeHiddenNodes: true, + }) + }, 20) + } + }) + }, [data]) + const memoNodeTypes = useMemo(() => ({ ...nodeTypes, ...defaultNodeTypes }), []) + return ( + + + + + + ) +} +function FlowWithProvider(props) { + return ( + + + + + + + + + + + + + ) +} +export default FlowWithProvider diff --git a/web/app/components/base/topology/loop-edges.tsx b/web/app/components/base/topology/loop-edges.tsx new file mode 100644 index 0000000000..b0cdd7d8ad --- /dev/null +++ b/web/app/components/base/topology/loop-edges.tsx @@ -0,0 +1,36 @@ +import React from 'react' +const nodeWidth = 180 +const nodeHeight = 60 +const CustomSelfLoopEdge = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + style = {}, + markerEndId, +}) => { + // 手动设置控制点,形成 3/4 圆的路径 + const diff = Math.max(Math.trunc((sourceX - targetX) / nodeWidth), 1) + const controlX1 = sourceX + (sourceX - targetX) // 控制点1的位置 + const controlY1 = sourceY - diff * nodeHeight * 2 + const controlX2 = sourceX - (sourceX - targetX) * 2 // 控制点2的位置 + const controlY2 = sourceY - diff * nodeHeight * 2 + + const edgePath = `M${sourceX},${sourceY} C${controlX1},${controlY1} ${controlX2},${controlY2} ${targetX},${targetY}` + return ( + <> + + + ) +} + +export default CustomSelfLoopEdge diff --git a/web/app/components/base/topology/service-node.tsx b/web/app/components/base/topology/service-node.tsx new file mode 100644 index 0000000000..2cb294d94e --- /dev/null +++ b/web/app/components/base/topology/service-node.tsx @@ -0,0 +1,36 @@ +import React, { useCallback } from 'react' +import { Handle, Position } from 'reactflow' +const handleStyle = { left: 10 } +const ServiceNode = React.memo(({ data, isConnectable }) => { + const onChange = useCallback((evt) => {}, []) + + return ( +
+ +
+
+
+
+ {data.label} +
{data.endpoint}
+
+
+ +
+ ) +}) + +export default ServiceNode diff --git a/web/app/components/run/index.tsx b/web/app/components/run/index.tsx new file mode 100644 index 0000000000..326731eb69 --- /dev/null +++ b/web/app/components/run/index.tsx @@ -0,0 +1,41 @@ +'use client' +import React, { useEffect, useState } from 'react' +import TextGeneration from '@/app/components/run/text-generation' +import Loading from '@/app/components/base/loading' +import { useStore as useAppStore } from '@/app/components/app/store' +import { fetchInstalledAppList } from '@/service/explore' +import type { InstalledApp } from '@/models/explore' +import useSWR from 'swr' +import { fetchApiKeysList } from '@/service/apps' +const TextGenerationApp = ({ installedApp }) => { + const { data: apiKeysList } = useSWR({ url: `/apps/${installedApp.app.id}/api-keys`, params: {} }, fetchApiKeysList) + return apiKeysList?.data?.length > 0 && +} +const WorkflowRunContainer = () => { + const appDetail = useAppStore(state => state.appDetail)! + const [installedApp, setInstalledApp] = useState() + + const getAppInfo = async () => { + const { installed_apps }: any = await fetchInstalledAppList(appDetail.id) || {} + setInstalledApp(installed_apps?.length > 0 ? installed_apps[0] : null) + } + useEffect(() => { + getAppInfo() + }, [appDetail.id]) + if (!installedApp) { + return ( +
+ +
+ ) + } + + return ( +
+ {installedApp.app.mode === 'workflow' && ( + + )} +
+ ) +} +export default React.memo(WorkflowRunContainer) diff --git a/web/app/components/run/node/llm-outputs.tsx b/web/app/components/run/node/llm-outputs.tsx new file mode 100644 index 0000000000..f659d8b524 --- /dev/null +++ b/web/app/components/run/node/llm-outputs.tsx @@ -0,0 +1,15 @@ +import { memo } from 'react' +import { Markdown } from '@/app/components/base/markdown' +type LLMOutputsProps = { + text: string; +} +const LLMOutputs = ({ text }: LLMOutputsProps) => { + return ( +
+
+ +
+
+ ) +} +export default memo(LLMOutputs) diff --git a/web/app/components/run/node/node.tsx b/web/app/components/run/node/node.tsx new file mode 100644 index 0000000000..ec2f160c26 --- /dev/null +++ b/web/app/components/run/node/node.tsx @@ -0,0 +1,233 @@ +'use client' +import { useTranslation } from 'react-i18next' +import type { FC } from 'react' +import { useCallback, useEffect, useState } from 'react' +import { + RiAlertFill, + RiArrowRightSLine, + RiCheckboxCircleFill, + RiErrorWarningLine, + RiLoader2Line, +} from '@remixicon/react' +import cn from '@/utils/classnames' +import StatusContainer from '@/app/components/workflow/run/status-container' +import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' +import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' +import type { + AgentLogItemWithChildren, + IterationDurationMap, + NodeTracing, +} from '@/types/workflow' +import ErrorHandleTip from '@/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip' +import { hasRetryNode } from '@/app/components/workflow/utils' +import { BlockEnum } from '../../workflow/types' +import BlockIcon from '../../workflow/block-icon' +import { IterationLogTrigger } from '../../workflow/run/iteration-log' +import { RetryLogTrigger } from '../../workflow/run/retry-log' +import { AgentLogTrigger } from '../../workflow/run/agent-log' +import DataDisplay from '../../workflow/run/data-display' +import LLMOutputs from './llm-outputs' + +type Props = { + className?: string + nodeInfo: NodeTracing + inMessage?: boolean + hideInfo?: boolean + hideProcessDetail?: boolean + onShowIterationDetail?: (detail: NodeTracing[][], iterDurationMap: IterationDurationMap) => void + onShowRetryDetail?: (detail: NodeTracing[]) => void + onShowAgentOrToolLog?: (detail?: AgentLogItemWithChildren) => void + notShowIterationNav?: boolean +} + +const NodePanel: FC = ({ + className, + nodeInfo, + inMessage = false, + hideInfo = false, + hideProcessDetail, + onShowIterationDetail, + onShowRetryDetail, + onShowAgentOrToolLog, + notShowIterationNav, +}) => { + const [collapseState, doSetCollapseState] = useState(true) + const setCollapseState = useCallback((state: boolean) => { + if (hideProcessDetail) + return + doSetCollapseState(state) + }, [hideProcessDetail]) + const { t } = useTranslation() + + const getTime = (time: number) => { + if (time < 1) + return `${(time * 1000).toFixed(3)} ms` + if (time > 60) + return `${Number.parseInt(Math.round(time / 60).toString())} m ${(time % 60).toFixed(3)} s` + return `${time.toFixed(3)} s` + } + + const getTokenCount = (tokens: number) => { + if (tokens < 1000) + return tokens + if (tokens >= 1000 && tokens < 1000000) + return `${Number.parseFloat((tokens / 1000).toFixed(3))}K` + if (tokens >= 1000000) + return `${Number.parseFloat((tokens / 1000000).toFixed(3))}M` + } + + useEffect(() => { + setCollapseState(!nodeInfo.expand) + }, [nodeInfo.expand, setCollapseState]) + + const isIterationNode = nodeInfo.node_type === BlockEnum.Iteration && !!nodeInfo.details?.length + const isRetryNode = hasRetryNode(nodeInfo.node_type) && !!nodeInfo.retryDetail?.length + const isAgentNode = nodeInfo.node_type === BlockEnum.Agent && !!nodeInfo.agentLog?.length + const isToolNode = nodeInfo.node_type === BlockEnum.Tool && !!nodeInfo.agentLog?.length + console.log(nodeInfo) + return ( +
+
+
setCollapseState(!collapseState)} + > + {!hideProcessDetail && ( + + )} + +
{nodeInfo.title}
+ {nodeInfo.status !== 'running' && !hideInfo && ( +
{nodeInfo.execution_metadata?.total_tokens ? `${getTokenCount(nodeInfo.execution_metadata?.total_tokens || 0)} tokens · ` : ''}{`${getTime(nodeInfo.elapsed_time || 0)}`}
+ )} + {nodeInfo.status === 'succeeded' && ( + + )} + {nodeInfo.status === 'failed' && ( + + )} + {nodeInfo.status === 'stopped' && ( + + )} + {nodeInfo.status === 'exception' && ( + + )} + {nodeInfo.status === 'running' && ( +
+ Running + +
+ )} +
+ { + nodeInfo.node_type === BlockEnum.LLM && nodeInfo.outputs?.text && + } + {nodeInfo.node_type === BlockEnum.Tool && (nodeInfo.outputs?.text) + &&
} + {!collapseState && !hideProcessDetail && ( +
+ {/* The nav to the iteration detail */} + {isIterationNode && !notShowIterationNav && onShowIterationDetail && ( + + )} + {isRetryNode && onShowRetryDetail && ( + + )} + { + (isAgentNode || isToolNode) && onShowAgentOrToolLog && ( + + ) + } +
+ {(nodeInfo.status === 'stopped') && ( + + {t('workflow.tracing.stopBy', { user: nodeInfo.created_by ? nodeInfo.created_by.name : 'N/A' })} + + )} + {(nodeInfo.status === 'exception') && ( + + {nodeInfo.error} + + {t('workflow.common.learnMore')} + + + )} + {nodeInfo.status === 'failed' && ( + + {nodeInfo.error} + + )} + {nodeInfo.status === 'retry' && ( + + {nodeInfo.error} + + )} +
+ {nodeInfo.inputs && ( +
+ {t('workflow.common.input').toLocaleUpperCase()}
} + language={CodeLanguage.json} + value={nodeInfo.inputs} + isJSONStringifyBeauty + /> +
+ )} + {nodeInfo.process_data && ( +
+ {t('workflow.common.processData').toLocaleUpperCase()}
} + language={CodeLanguage.json} + value={nodeInfo.process_data} + isJSONStringifyBeauty + /> +
+ )} + {nodeInfo.outputs && ( +
+ {t('workflow.common.output').toLocaleUpperCase()}
} + language={CodeLanguage.json} + value={nodeInfo.outputs} + isJSONStringifyBeauty + tip={} + /> +
+ )} + + + )} + + + ) +} + +export default NodePanel diff --git a/web/app/components/run/text-generation/icons/star.svg b/web/app/components/run/text-generation/icons/star.svg new file mode 100644 index 0000000000..e86a14285e --- /dev/null +++ b/web/app/components/run/text-generation/icons/star.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/app/components/run/text-generation/index.tsx b/web/app/components/run/text-generation/index.tsx new file mode 100644 index 0000000000..164c757ab7 --- /dev/null +++ b/web/app/components/run/text-generation/index.tsx @@ -0,0 +1,679 @@ +'use client' +import type { FC } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + RiErrorWarningFill, +} from '@remixicon/react' +import { useBoolean, useClickAway } from 'ahooks' +import { XMarkIcon } from '@heroicons/react/24/outline' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import Button from '../../base/button' +import { checkOrSetAccessToken } from '../utils' +import s from './style.module.css' +import ResDownload from './run-batch/res-download' +import cn from '@/utils/classnames' +import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import RunOnce from '@/app/components/run/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, + TextToSpeechConfig, +} from '@/models/debug' +import AppIcon from '@/app/components/base/app-icon' +import { changeLanguage } from '@/i18n/i18next-config' +import Loading from '@/app/components/base/loading' +import { userInputsFormToPromptVariables } from '@/utils/model-config' +import Res from '@/app/components/run/text-generation/result' +import type { InstalledApp } from '@/models/explore' +import { DEFAULT_VALUE_MAX_LEN, appDefaultIconBackground } from '@/config' +import Toast from '@/app/components/base/toast' +import type { VisionFile, VisionSettings } from '@/types/app' +import { Resolution, TransferMethod } from '@/types/app' +import { useAppFavicon } from '@/hooks/use-app-favicon' + +const GROUP_SIZE = 5 // to avoid RPM(Request per minute) limit. The group task finished then the next group. +enum TaskStatus { + pending = 'pending', + running = 'running', + completed = 'completed', + failed = 'failed', +} + +type TaskParam = { + inputs: Record +} + +type Task = { + id: number + status: TaskStatus + params: TaskParam +} + +export type IMainProps = { + isInstalledApp?: boolean + installedAppInfo?: InstalledApp + isWorkflow?: boolean + apiKey: string +} + +const TextGeneration: FC = ({ + isInstalledApp = false, + installedAppInfo, + isWorkflow = false, + apiKey, +}) => { + const { notify } = Toast + + const { t } = useTranslation() + const media = useBreakpoints() + const isPC = media === MediaType.pc + const isTablet = media === MediaType.tablet + const isMobile = media === MediaType.mobile + + const searchParams = useSearchParams() + const mode = searchParams.get('mode') || 'create' + const [currentTab, setCurrentTab] = useState(['create', 'batch'].includes(mode) ? mode : 'create') + + const router = useRouter() + const pathname = usePathname() + useEffect(() => { + const params = new URLSearchParams(searchParams) + if (params.has('mode')) { + params.delete('mode') + router.replace(`${pathname}?${params.toString()}`) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Notice this situation isCallBatchAPI but not in batch tab + const [isCallBatchAPI, setIsCallBatchAPI] = useState(false) + const isInBatchTab = currentTab === 'batch' + const [inputs, doSetInputs] = useState>({}) + const inputsRef = useRef(inputs) + const setInputs = useCallback((newInputs: Record) => { + doSetInputs(newInputs) + inputsRef.current = newInputs + }, []) + const [appId, setAppId] = useState('') + const [siteInfo, setSiteInfo] = useState(null) + const [canReplaceLogo, setCanReplaceLogo] = useState(false) + const [promptConfig, setPromptConfig] = useState(null) + const [moreLikeThisConfig, setMoreLikeThisConfig] = useState(null) + const [textToSpeechConfig, setTextToSpeechConfig] = useState(null) + + // save message + const [savedMessages, setSavedMessages] = useState([]) + const fetchSavedMessage = async () => { + const res: any = await doFetchSavedMessage(isInstalledApp, installedAppInfo?.id) + setSavedMessages(res.data) + } + 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() + } + + // send message task + const [controlSend, setControlSend] = useState(0) + const [controlStopResponding, setControlStopResponding] = useState(0) + const [visionConfig, setVisionConfig] = useState({ + enabled: false, + number_limits: 2, + detail: Resolution.low, + transfer_methods: [TransferMethod.local_file], + }) + const [completionFiles, setCompletionFiles] = useState([]) + + const handleSend = () => { + setIsCallBatchAPI(false) + setControlSend(Date.now()) + + // eslint-disable-next-line ts/no-use-before-define + setAllTaskList([]) // clear batch task running status + + // eslint-disable-next-line ts/no-use-before-define + showResSidebar() + } + + const [controlRetry, setControlRetry] = useState(0) + const handleRetryAllFailedTask = () => { + setControlRetry(Date.now()) + } + const [allTaskList, doSetAllTaskList] = useState([]) + const allTaskListRef = useRef([]) + const getLatestTaskList = () => allTaskListRef.current + const setAllTaskList = (taskList: Task[]) => { + doSetAllTaskList(taskList) + allTaskListRef.current = taskList + } + const pendingTaskList = allTaskList.filter(task => task.status === TaskStatus.pending) + const noPendingTask = pendingTaskList.length === 0 + const showTaskList = allTaskList.filter(task => task.status !== TaskStatus.pending) + const [currGroupNum, doSetCurrGroupNum] = useState(0) + const currGroupNumRef = useRef(0) + const setCurrGroupNum = (num: number) => { + doSetCurrGroupNum(num) + currGroupNumRef.current = num + } + const getCurrGroupNum = () => { + return currGroupNumRef.current + } + const allSuccessTaskList = allTaskList.filter(task => task.status === TaskStatus.completed) + const allFailedTaskList = allTaskList.filter(task => task.status === TaskStatus.failed) + const allTasksFinished = allTaskList.every(task => task.status === TaskStatus.completed) + const allTasksRun = allTaskList.every(task => [TaskStatus.completed, TaskStatus.failed].includes(task.status)) + const [batchCompletionRes, doSetBatchCompletionRes] = useState>({}) + const batchCompletionResRef = useRef>({}) + const setBatchCompletionRes = (res: Record) => { + doSetBatchCompletionRes(res) + batchCompletionResRef.current = res + } + const getBatchCompletionRes = () => batchCompletionResRef.current + const exportRes = allTaskList.map((task) => { + const batchCompletionResLatest = getBatchCompletionRes() + const res: Record = {} + const { inputs } = task.params + promptConfig?.prompt_variables.forEach((v) => { + res[v.name] = inputs[v.key] + }) + let result = batchCompletionResLatest[task.id] + // task might return multiple fields, should marshal object to string + if (typeof batchCompletionResLatest[task.id] === 'object') + result = JSON.stringify(result) + + res[t('share.generation.completionResult')] = result + return res + }) + const checkBatchInputs = (data: string[][]) => { + if (!data || data.length === 0) { + notify({ type: 'error', message: t('share.generation.errorMsg.empty') }) + return false + } + const headerData = data[0] + let isMapVarName = true + promptConfig?.prompt_variables.forEach((item, index) => { + if (!isMapVarName) + return + + if (item.name !== headerData[index]) + isMapVarName = false + }) + + if (!isMapVarName) { + notify({ type: 'error', message: t('share.generation.errorMsg.fileStructNotMatch') }) + return false + } + + 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 = '' + let moreThanMaxLengthVarName = '' + let maxLength = 0 + payloadData.forEach((item, index) => { + if (errorRowIndex !== 0) + return + + promptConfig?.prompt_variables.forEach((varItem, varIndex) => { + if (errorRowIndex !== 0) + return + if (varItem.type === 'string') { + const maxLen = varItem.max_length || DEFAULT_VALUE_MAX_LEN + if (item[varIndex].length > maxLen) { + moreThanMaxLengthVarName = varItem.name + maxLength = maxLen + errorRowIndex = index + 1 + return + } + } + if (!varItem.required) + return + + if (item[varIndex].trim() === '') { + requiredVarName = varItem.name + errorRowIndex = index + 1 + } + }) + }) + + if (errorRowIndex !== 0) { + if (requiredVarName) + notify({ type: 'error', message: t('share.generation.errorMsg.invalidLine', { rowIndex: errorRowIndex + 1, varName: requiredVarName }) }) + + if (moreThanMaxLengthVarName) + notify({ type: 'error', message: t('share.generation.errorMsg.moreThanMaxLengthLine', { rowIndex: errorRowIndex + 1, varName: moreThanMaxLengthVarName, maxLength }) }) + + return false + } + return true + } + const handleRunBatch = (data: string[][]) => { + if (!checkBatchInputs(data)) + return + if (!allTasksFinished) { + notify({ type: 'info', message: t('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 < GROUP_SIZE ? TaskStatus.running : TaskStatus.pending, + params: { + inputs, + }, + } + }) + setAllTaskList(allTaskList) + setCurrGroupNum(0) + setControlSend(Date.now()) + // clear run once task status + setControlStopResponding(Date.now()) + + // eslint-disable-next-line ts/no-use-before-define + showResSidebar() + } + const handleCompleted = (completionRes: string, taskId?: number, isSuccess?: boolean) => { + const allTaskListLatest = getLatestTaskList() + const batchCompletionResLatest = getBatchCompletionRes() + const pendingTaskList = allTaskListLatest.filter(task => task.status === TaskStatus.pending) + const runTasksCount = 1 + allTaskListLatest.filter(task => [TaskStatus.completed, TaskStatus.failed].includes(task.status)).length + const needToAddNextGroupTask = (getCurrGroupNum() !== runTasksCount) && pendingTaskList.length > 0 && (runTasksCount % GROUP_SIZE === 0 || (allTaskListLatest.length - runTasksCount < GROUP_SIZE)) + // avoid add many task at the same time + if (needToAddNextGroupTask) + setCurrGroupNum(runTasksCount) + + const nextPendingTaskIds = needToAddNextGroupTask ? pendingTaskList.slice(0, GROUP_SIZE).map(item => item.id) : [] + const newAllTaskList = allTaskListLatest.map((item) => { + if (item.id === taskId) { + return { + ...item, + status: isSuccess ? TaskStatus.completed : TaskStatus.failed, + } + } + if (needToAddNextGroupTask && nextPendingTaskIds.includes(item.id)) { + return { + ...item, + status: TaskStatus.running, + } + } + return item + }) + setAllTaskList(newAllTaskList) + if (taskId) { + setBatchCompletionRes({ + ...batchCompletionResLatest, + [`${taskId}`]: completionRes, + }) + } + } + + const fetchInitData = async () => { + if (!isInstalledApp) + await checkOrSetAccessToken() + + return Promise.all([ + isInstalledApp + ? { + app_id: installedAppInfo?.id, + site: { + title: installedAppInfo?.app.name, + prompt_public: false, + copyright: '', + icon: installedAppInfo?.app.icon, + icon_background: installedAppInfo?.app.icon_background, + }, + plan: 'basic', + } + : fetchAppInfo(), + fetchAppParams(isInstalledApp, installedAppInfo?.id), + !isWorkflow + ? fetchSavedMessage() + : {}, + ]) + } + useEffect(() => { + (async () => { + const [appData, appParams]: any = await fetchInitData() + const { app_id: appId, site: siteInfo, can_replace_logo } = appData + setAppId(appId) + setSiteInfo(siteInfo as SiteInfo) + setCanReplaceLogo(can_replace_logo) + changeLanguage(siteInfo.default_language) + + const { user_input_form, more_like_this, file_upload, text_to_speech }: any = appParams + setVisionConfig({ + // legacy of image upload compatible + ...file_upload, + transfer_methods: file_upload.allowed_file_upload_methods || file_upload.allowed_upload_methods, + // legacy of image upload compatible + image_file_size_limit: appParams?.system_parameters?.image_file_size_limit, + fileUploadConfig: appParams?.system_parameters, + }) + const prompt_variables = userInputsFormToPromptVariables(user_input_form) + setPromptConfig({ + prompt_template: '', // placeholder for future + prompt_variables, + } as PromptConfig) + setMoreLikeThisConfig(more_like_this) + setTextToSpeechConfig(text_to_speech) + })() + }, []) + + // Can Use metadata(https://beta.nextjs.org/docs/api-reference/metadata) to set title. But it only works in server side client. + useEffect(() => { + if (siteInfo?.title) { + if (canReplaceLogo) + document.title = `${siteInfo.title}` + else + document.title = `${siteInfo.title} - Powered by Dify` + } + }, [siteInfo?.title, canReplaceLogo]) + + useAppFavicon({ + enable: !isInstalledApp, + icon_type: siteInfo?.icon_type, + icon: siteInfo?.icon, + icon_background: siteInfo?.icon_background, + icon_url: siteInfo?.icon_url, + }) + + const [isShowResSidebar, { setTrue: doShowResSidebar, setFalse: hideResSidebar }] = useBoolean(false) + const showResSidebar = () => { + // fix: useClickAway hideResSidebar will close sidebar + setTimeout(() => { + doShowResSidebar() + }, 0) + } + const resRef = useRef(null) + useClickAway(() => { + hideResSidebar() + }, resRef) + + const renderRes = (task?: Task) => () + + const renderBatchRes = () => { + return (showTaskList.map(task => renderRes(task))) + } + + const resWrapClassNames = (() => { + if (isPC) + return 'grow h-full' + + if (!isShowResSidebar) + return 'none' + + return cn('fixed z-50 inset-0', isTablet ? 'pl-[128px]' : 'pl-6') + })() + + const renderResWrap = ( +
+ <> +
+
+
+
{t('share.generation.title')}
+
+
+ {allFailedTaskList.length > 0 && ( +
+ +
{t('share.generation.batchFailed.info', { num: allFailedTaskList.length })}
+ +
+
+ )} + {allSuccessTaskList.length > 0 && ( + + )} + {!isPC && ( +
+ +
+ )} +
+
+ +
+ {!isCallBatchAPI ? renderRes() : renderBatchRes()} + {!noPendingTask && ( +
+ +
+ )} +
+ +
+ ) + + if (!appId || !siteInfo || !promptConfig) { + return ( +
+ +
) + } + + return ( + <> +
+ {/* Left */} +
+
+
+
+ +
{siteInfo.title}
+
+ {!isPC && ( + + )} +
+ {siteInfo.description && ( +
{siteInfo.description}
+ )} +
+ {/* 0 + ? ( +
+ {savedMessages.length} +
+ ) + : null, + }] + : []), + ]} + value={currentTab} + onChange={setCurrentTab} + /> */} +
+ {/*
*/} + + {/*
*/} + {/*
+ +
+ + {currentTab === 'saved' && ( + setCurrentTab('create')} + /> + )} */} +
+ + {/* copyright */} +
+
© {siteInfo.copyright || siteInfo.title} {(new Date()).getFullYear()}
+ {siteInfo.privacy_policy && ( + <> +
·
+
{t('share.chat.privacyPolicyLeft')} + {t('share.chat.privacyPolicyMiddle')} + {t('share.chat.privacyPolicyRight')} +
+ + )} +
+
+ + {/* Result */} +
+ {renderResWrap} +
+
+ + ) +} + +export default TextGeneration diff --git a/web/app/components/run/text-generation/no-data/index.tsx b/web/app/components/run/text-generation/no-data/index.tsx new file mode 100644 index 0000000000..7492614131 --- /dev/null +++ b/web/app/components/run/text-generation/no-data/index.tsx @@ -0,0 +1,26 @@ +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' + +const StarIcon = ( + + + + +) + +export type INoDataProps = {} +const NoData: FC = () => { + const { t } = useTranslation() + return ( +
+ {StarIcon} +
+ {t('share.generation.noData')} +
+
+ ) +} +export default React.memo(NoData) diff --git a/web/app/components/run/text-generation/result/content.tsx b/web/app/components/run/text-generation/result/content.tsx new file mode 100644 index 0000000000..4e39db42c8 --- /dev/null +++ b/web/app/components/run/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/base/chat/chat/type' +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/run/text-generation/result/header.tsx b/web/app/components/run/text-generation/result/header.tsx new file mode 100644 index 0000000000..0233b098d0 --- /dev/null +++ b/web/app/components/run/text-generation/result/header.tsx @@ -0,0 +1,113 @@ +'use client' +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { ClipboardDocumentIcon, HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline' +import copy from 'copy-to-clipboard' +import type { FeedbackType } from '@/app/components/base/chat/chat/type' +import Button from '@/app/components/base/button' +import Toast from '@/app/components/base/toast' +import Tooltip from '@/app/components/base/tooltip' + +type IResultHeaderProps = { + result: string + showFeedback: boolean + feedback: FeedbackType + onFeedback: (feedback: FeedbackType) => void +} + +const Header: FC = ({ + feedback, + showFeedback, + onFeedback, + result, +}) => { + const { t } = useTranslation() + return ( +
+
{t('share.generation.resultTitle')}
+
+ + + {showFeedback && feedback.rating && feedback.rating === 'like' && ( + +
{ + onFeedback({ + rating: null, + }) + }} + className='flex w-7 h-7 items-center justify-center rounded-md cursor-pointer !text-primary-600 border border-primary-200 bg-primary-100 hover:border-primary-300 hover:bg-primary-200'> + +
+
+ )} + + {showFeedback && feedback.rating && feedback.rating === 'dislike' && ( + +
{ + onFeedback({ + rating: null, + }) + }} + className='flex w-7 h-7 items-center justify-center rounded-md cursor-pointer !text-red-600 border border-red-200 bg-red-100 hover:border-red-300 hover:bg-red-200'> + +
+
+ )} + + {showFeedback && !feedback.rating && ( +
+ +
{ + onFeedback({ + rating: 'like', + }) + }} + className='flex w-6 h-6 items-center justify-center rounded-md cursor-pointer hover:bg-gray-100'> + +
+
+ +
{ + onFeedback({ + rating: 'dislike', + }) + }} + className='flex w-6 h-6 items-center justify-center rounded-md cursor-pointer hover:bg-gray-100'> + +
+
+
+ )} +
+ +
+ ) +} + +export default React.memo(Header) diff --git a/web/app/components/run/text-generation/result/index.tsx b/web/app/components/run/text-generation/result/index.tsx new file mode 100644 index 0000000000..90b38df6ab --- /dev/null +++ b/web/app/components/run/text-generation/result/index.tsx @@ -0,0 +1,401 @@ +'use client' +import type { FC } from 'react' +import React, { useEffect, useRef, useState } from 'react' +import { useBoolean } from 'ahooks' +import { t } from 'i18next' +import produce from 'immer' +import cn from '@/utils/classnames' +import NoData from '@/app/components/share/text-generation/no-data' +import Toast from '@/app/components/base/toast' +import { sendWorkflowRun, updateFeedback } from '@/service/share' +import type { FeedbackType } from '@/app/components/base/chat/chat/type' +import Loading from '@/app/components/base/loading' +import type { PromptConfig } from '@/models/debug' +import type { InstalledApp } from '@/models/explore' +import type { ModerationService } from '@/models/common' +import { TransferMethod, type VisionFile, type VisionSettings } from '@/types/app' +import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types' +import type { WorkflowProcess } from '@/app/components/base/chat/types' +import { sleep } from '@/utils' +import type { SiteInfo } from '@/models/share' +import { TEXT_GENERATION_TIMEOUT_MS } from '@/config' +import { + getFilesInLogs, +} from '@/app/components/base/file-uploader/utils' +import TracingPanel from '@/app/components/workflow/run/tracing-panel' +import { useStore } from '@/app/components/app/store' + +export type IResultProps = { + isWorkflow: boolean + isCallBatchAPI: boolean + isPC: boolean + isMobile: boolean + isInstalledApp: boolean + installedAppInfo?: InstalledApp + isError: boolean + isShowTextToSpeech: boolean + promptConfig: PromptConfig | null + moreLikeThisEnabled: boolean + inputs: Record + controlSend?: number + controlRetry?: number + controlStopResponding?: number + onShowRes: () => void + handleSaveMessage: (messageId: string) => void + taskId?: number + onCompleted: (completionRes: string, taskId?: number, success?: boolean) => void + enableModeration?: boolean + moderationService?: (text: string) => ReturnType + visionConfig: VisionSettings + completionFiles: VisionFile[] + siteInfo: SiteInfo | null + apiKey: string +} + +const Result: FC = ({ + isWorkflow, + isCallBatchAPI, + isPC, + isMobile, + isInstalledApp, + installedAppInfo, + isError, + isShowTextToSpeech, + promptConfig, + moreLikeThisEnabled, + inputs, + controlSend, + controlRetry, + controlStopResponding, + onShowRes, + handleSaveMessage, + taskId, + onCompleted, + visionConfig, + completionFiles, + siteInfo, + apiKey, +}) => { + const appDetail = useStore(state => state.appDetail)! + const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false) + useEffect(() => { + if (controlStopResponding) + setRespondingFalse() + }, [controlStopResponding]) + + const [completionRes, doSetCompletionRes] = useState('') + const completionResRef = useRef() + const setCompletionRes = (res: any) => { + completionResRef.current = res + doSetCompletionRes(res) + } + const getCompletionRes = () => completionResRef.current + const [workflowProcessData, doSetWorkflowProcessData] = useState() + const workflowProcessDataRef = useRef() + const setWorkflowProcessData = (data: WorkflowProcess) => { + workflowProcessDataRef.current = data + doSetWorkflowProcessData(data) + } + const getWorkflowProcessData = () => workflowProcessDataRef.current + + 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) { + if (completionFiles.find(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) { + notify({ type: 'info', message: t('appDebug.errorMessage.waitForFileUpload') }) + return false + } + return true + } + + let hasEmptyInput = '' + 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, name }) => { + if (hasEmptyInput) + return + + if (!inputs[key]) + hasEmptyInput = name + }) + + if (hasEmptyInput) { + logError(t('appDebug.errorMessage.valueOfVarRequired', { key: hasEmptyInput })) + return false + } + + if (completionFiles.find(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) { + notify({ type: 'info', message: t('appDebug.errorMessage.waitForFileUpload') }) + return false + } + return !hasEmptyInput + } + + const handleSend = async () => { + if (isResponding) { + notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') }) + return false + } + + if (!checkCanSend()) + return + + const data: Record = { + inputs, + } + if (visionConfig.enabled && completionFiles && completionFiles?.length > 0) { + data.files = completionFiles.map((item) => { + if (item.transfer_method === TransferMethod.local_file) { + return { + ...item, + url: '', + } + } + return item + }) + } + + setMessageId(null) + setFeedback({ + rating: null, + }) + setCompletionRes('') + + const res: string[] = [] + let tempMessageId = '' + + if (!isPC) + onShowRes() + + setRespondingTrue() + let isEnd = false + let isTimeout = false; + (async () => { + await sleep(TEXT_GENERATION_TIMEOUT_MS) + if (!isEnd) { + setRespondingFalse() + onCompleted(getCompletionRes(), taskId, false) + isTimeout = true + } + })() + data.user = 'test' + if (isWorkflow) { + sendWorkflowRun( + data, + { + onWorkflowStarted: ({ workflow_run_id }) => { + tempMessageId = workflow_run_id + setWorkflowProcessData({ + status: WorkflowRunningStatus.Running, + tracing: [], + expand: false, + resultText: '', + }) + }, + onIterationStart: ({ data }) => { + setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { + draft.expand = true + draft.tracing!.push({ + ...data, + status: NodeRunningStatus.Running, + expand: true, + } as any) + })) + }, + onIterationNext: () => { + setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { + draft.expand = true + const iterations = draft.tracing.find(item => item.node_id === data.node_id + && (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))! + iterations?.details!.push([]) + })) + }, + onIterationFinish: ({ data }) => { + setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { + draft.expand = true + const iterationsIndex = draft.tracing.findIndex(item => item.node_id === data.node_id + && (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))! + draft.tracing[iterationsIndex] = { + ...data, + expand: !!data.error, + } as any + })) + }, + onNodeStarted: ({ data }) => { + if (data.iteration_id) + return + + setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { + draft.expand = true + draft.tracing!.push({ + ...data, + status: NodeRunningStatus.Running, + expand: true, + } as any) + })) + }, + onNodeFinished: ({ data }) => { + if (data.iteration_id) + return + + setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { + const currentIndex = draft.tracing!.findIndex(trace => trace.node_id === data.node_id + && (trace.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || trace.parallel_id === data.execution_metadata?.parallel_id)) + if (currentIndex > -1 && draft.tracing) { + draft.tracing[currentIndex] = { + ...(draft.tracing[currentIndex].extras + ? { extras: draft.tracing[currentIndex].extras } + : {}), + ...data, + expand: !!data.error, + } as any + } + })) + }, + onWorkflowFinished: ({ data }) => { + if (isTimeout) { + notify({ type: 'warning', message: t('appDebug.warningMessage.timeoutExceeded') }) + return + } + if (data.error) { + notify({ type: 'error', message: data.error }) + setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { + draft.status = WorkflowRunningStatus.Failed + })) + setRespondingFalse() + onCompleted(getCompletionRes(), taskId, false) + isEnd = true + return + } + setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { + draft.status = WorkflowRunningStatus.Succeeded + draft.files = getFilesInLogs(data.outputs || []) as any[] + })) + if (!data.outputs) { + setCompletionRes('') + } + else { + setCompletionRes(data.outputs) + const isStringOutput = Object.keys(data.outputs).length === 1 && typeof data.outputs[Object.keys(data.outputs)[0]] === 'string' + if (isStringOutput) { + setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { + draft.resultText = data.outputs[Object.keys(data.outputs)[0]] + })) + } + } + setRespondingFalse() + setMessageId(tempMessageId) + onCompleted(getCompletionRes(), taskId, true) + isEnd = true + }, + onTextChunk: (params) => { + const { data: { text } } = params + setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { + draft.resultText += text + })) + }, + onTextReplace: (params) => { + const { data: { text } } = params + setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { + draft.resultText = text + })) + }, + }, + isInstalledApp, + apiKey, + ) + } + } + + const [controlClearMoreLikeThis, setControlClearMoreLikeThis] = useState(0) + useEffect(() => { + if (controlSend) { + handleSend() + setControlClearMoreLikeThis(Date.now()) + } + }, [controlSend]) + + useEffect(() => { + if (controlRetry) + handleSend() + }, [controlRetry]) + + const renderTextGenerationRes = () => ( + + // + ) + + return ( +
+ + { + !isCallBatchAPI && isWorkflow && ( + (isResponding && !workflowProcessData) + ? ( +
+ +
+ ) + : !workflowProcessData + ? + : renderTextGenerationRes() + ) + } + {isCallBatchAPI && ( +
+ {renderTextGenerationRes()} +
+ )} +
+ ) +} +export default React.memo(Result) diff --git a/web/app/components/run/text-generation/run-batch/csv-download/index.tsx b/web/app/components/run/text-generation/run-batch/csv-download/index.tsx new file mode 100644 index 0000000000..2d50725b54 --- /dev/null +++ b/web/app/components/run/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] + 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/run/text-generation/run-batch/csv-reader/index.tsx b/web/app/components/run/text-generation/run-batch/csv-reader/index.tsx new file mode 100644 index 0000000000..ac51bca6e6 --- /dev/null +++ b/web/app/components/run/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 { useTranslation } from 'react-i18next' +import s from './style.module.css' +import cn from '@/utils/classnames' +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/run/text-generation/run-batch/csv-reader/style.module.css b/web/app/components/run/text-generation/run-batch/csv-reader/style.module.css new file mode 100644 index 0000000000..ff0b6aa157 --- /dev/null +++ b/web/app/components/run/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/run/text-generation/run-batch/index.tsx b/web/app/components/run/text-generation/run-batch/index.tsx new file mode 100644 index 0000000000..2a632f9cfc --- /dev/null +++ b/web/app/components/run/text-generation/run-batch/index.tsx @@ -0,0 +1,59 @@ +'use client' +import type { FC } from 'react' +import React from 'react' +import { + PlayIcon, +} from '@heroicons/react/24/solid' +import { useTranslation } from 'react-i18next' +import { + RiLoader2Line, +} from '@remixicon/react' +import CSVReader from './csv-reader' +import CSVDownload from './csv-download' +import cn from '@/utils/classnames' +import Button from '@/app/components/base/button' +export type IRunBatchProps = { + vars: { name: string }[] + onSend: (data: string[][]) => void + isAllFinished: boolean +} + +const RunBatch: FC = ({ + vars, + onSend, + isAllFinished, +}) => { + 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) + } + const Icon = isAllFinished ? PlayIcon : RiLoader2Line + return ( +
+ + +
+
+ +
+
+ ) +} +export default React.memo(RunBatch) diff --git a/web/app/components/run/text-generation/run-batch/res-download/index.tsx b/web/app/components/run/text-generation/run-batch/res-download/index.tsx new file mode 100644 index 0000000000..f835ff70b9 --- /dev/null +++ b/web/app/components/run/text-generation/run-batch/res-download/index.tsx @@ -0,0 +1,41 @@ +'use client' +import type { FC } from 'react' +import React from 'react' +import { + useCSVDownloader, +} from 'react-papaparse' +import { useTranslation } from 'react-i18next' +import cn from '@/utils/classnames' +import { Download02 as DownloadIcon } from '@/app/components/base/icons/src/vender/solid/general' +import Button from '@/app/components/base/button' +export type IResDownloadProps = { + isMobile: boolean + values: Record[] +} + +const ResDownload: FC = ({ + isMobile, + values, +}) => { + const { t } = useTranslation() + const { CSVDownloader, Type } = useCSVDownloader() + + return ( + + + + ) +} +export default React.memo(ResDownload) diff --git a/web/app/components/run/text-generation/run-once/index.tsx b/web/app/components/run/text-generation/run-once/index.tsx new file mode 100644 index 0000000000..f46713a514 --- /dev/null +++ b/web/app/components/run/text-generation/run-once/index.tsx @@ -0,0 +1,168 @@ +import type { FC, FormEvent } from 'react' +import React, { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { + PlayIcon, +} from '@heroicons/react/24/solid' +import Select from '@/app/components/base/select' +import type { SiteInfo } from '@/models/share' +import type { PromptConfig } from '@/models/debug' +import Button from '@/app/components/base/button' +import Textarea from '@/app/components/base/textarea' +import { DEFAULT_VALUE_MAX_LEN } from '@/config' +import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader' +import type { VisionFile, VisionSettings } from '@/types/app' +import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader' +import { getProcessedFiles } from '@/app/components/base/file-uploader/utils' + +export type IRunOnceProps = { + siteInfo: SiteInfo + promptConfig: PromptConfig + inputs: Record + inputsRef: React.MutableRefObject> + onInputsChange: (inputs: Record) => void + onSend: () => void + visionConfig: VisionSettings + onVisionFilesChange: (files: VisionFile[]) => void +} +const RunOnce: FC = ({ + promptConfig, + inputs, + inputsRef, + onInputsChange, + onSend, + visionConfig, + onVisionFilesChange, +}) => { + const { t } = useTranslation() + + const onClear = () => { + const newInputs: Record = {} + promptConfig.prompt_variables.forEach((item) => { + newInputs[item.key] = '' + }) + onInputsChange(newInputs) + } + + const onSubmit = (e: FormEvent) => { + e.preventDefault() + onSend() + } + + const handleInputsChange = useCallback((newInputs: Record) => { + onInputsChange(newInputs) + inputsRef.current = newInputs + }, [onInputsChange, inputsRef]) + + return ( +
+
+ {/* input form */} +
+ {promptConfig.prompt_variables.map(item => ( +
+ +
+ {item.type === 'select' && ( + { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }} + maxLength={item.max_length || DEFAULT_VALUE_MAX_LEN} + /> + )} + {item.type === 'paragraph' && ( +