chore: frontend

pull/17608/head
keting lu 1 year ago
parent 9d88021575
commit ec9eb03ae1

@ -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"

@ -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<IAppDetailLayoutProps> = (props) => {
return (
<div className={cn(s.app, 'flex relative', 'overflow-hidden')}>
{appDetail && (
{/* {appDetail && (
<AppSideBar title={appDetail.name} icon={appDetail.icon} icon_background={appDetail.icon_background as string} desc={appDetail.mode} navigation={navigation} />
)}
)} */}
<div className="bg-components-panel-bg grow overflow-hidden">
{children}
</div>

@ -0,0 +1,12 @@
'use client'
import WorkflowRunContainer from '@/app/components/run'
const Page = () => {
return (
<div className='w-full h-full overflow-x-auto'>
<WorkflowRunContainer />
</div>
)
}
export default Page

@ -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' && <div className='truncate'>{t('app.types.completion').toUpperCase()}</div>}
</div>
</div>
<div>
<Button onClick={(e) => {
e.preventDefault()
e.stopPropagation()
push(`app/${app.id}/run`)
}}></Button>
</div>
</div>
<div className='title-wrapper h-[90px] px-[14px] text-xs leading-normal text-text-tertiary'>
<div

@ -6,10 +6,7 @@ import useSWRInfinite from 'swr/infinite'
import { useTranslation } from 'react-i18next'
import { useDebounceFn } from 'ahooks'
import {
RiApps2Line,
RiExchange2Line,
RiMessage3Line,
RiRobot3Line,
} from '@remixicon/react'
import AppCard from './AppCard'
import NewAppCard from './NewAppCard'
@ -78,9 +75,9 @@ const Apps = () => {
const anchorRef = useRef<HTMLDivElement>(null)
const options = [
{ value: 'all', text: t('app.types.all'), icon: <RiApps2Line className='w-[14px] h-[14px] mr-1' /> },
{ value: 'chat', text: t('app.types.chatbot'), icon: <RiMessage3Line className='w-[14px] h-[14px] mr-1' /> },
{ value: 'agent-chat', text: t('app.types.agent'), icon: <RiRobot3Line className='w-[14px] h-[14px] mr-1' /> },
// { value: 'all', text: t('app.types.all'), icon: <RiApps2Line className='w-[14px] h-[14px] mr-1' /> },
// { value: 'chat', text: t('app.types.chatbot'), icon: <RiMessage3Line className='w-[14px] h-[14px] mr-1' /> },
// { value: 'agent-chat', text: t('app.types.agent'), icon: <RiRobot3Line className='w-[14px] h-[14px] mr-1' /> },
{ value: 'workflow', text: t('app.types.workflow'), icon: <RiExchange2Line className='w-[14px] h-[14px] mr-1' /> },
]

@ -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<AppMode>('chat')
const [appMode, setAppMode] = useState<AppMode>('workflow')
const [appIcon, setAppIcon] = useState<AppIconSelection>({ type: 'emoji', icon: '🤖', background: '#FFEAD5' })
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const [name, setName] = useState('')
@ -105,7 +104,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps)
<span className='system-sm-semibold text-text-secondary'>{t('app.newApp.chooseAppType')}</span>
</div>
<div className='flex flex-col w-[660px] gap-4'>
<div>
{/* <div>
<div className='mb-2'>
<span className='system-2xs-medium-uppercase text-text-tertiary'>{t('app.newApp.forBeginners')}</span>
</div>
@ -141,13 +140,13 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps)
setAppMode('completion')
}} />
</div>
</div>
</div> */}
<div>
<div className='mb-2'>
<span className='system-2xs-medium-uppercase text-text-tertiary'>{t('app.newApp.forAdvanced')}</span>
</div>
<div className='flex flex-row gap-2'>
<AppTypeCard
{/* <AppTypeCard
beta
active={appMode === 'advanced-chat'}
title={t('app.types.advanced')}
@ -157,7 +156,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps)
</div>}
onClick={() => {
setAppMode('advanced-chat')
}} />
}} /> */}
<AppTypeCard
beta
active={appMode === 'workflow'}

@ -0,0 +1,39 @@
export const anormalTypesOptions = [
{
value: 'unknown',
key: 0,
label: '未知',
hide: true,
},
{
value: 'app',
key: 1,
label: '应用接口异常类型',
},
{
value: 'container',
key: 2,
label: '容器异常类型',
},
{
value: 'infra',
key: 3,
label: '基础设施异常类型',
},
{
value: 'network',
key: 4,
label: '网络异常类型',
},
{
value: 'error',
key: 5,
label: 'JAVA Exception',
},
{
value: 'appInstance',
key: 6,
label: '应用实例异常类型',
},
]
export const anormalTypes = ['app', 'container', 'infra', 'network', 'error', 'appInstance']

@ -0,0 +1,319 @@
import React, { useEffect, useRef, useState } from 'react'
import { VariableSizeList as List } from 'react-window'
import Tag from '../tag'
import dayjs from 'dayjs'
import { anormalTypes, anormalTypesOptions } from './constant'
import cn from '@/utils/classnames'
type AlertListComProps = {
finalAnormalEvents: any[]
deltaAnormalEvents: any[]
selectedNodeInfo: any
}
type AnormalStatus = 'startFiring' | 'updatedFiring' | 'resolved' | 'all'
const AlertList = ({
finalAnormalEvents = [],
deltaAnormalEvents = [],
selectedNodeInfo = null,
}: AlertListComProps) => {
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<AnormalStatus>('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: (
<Tag color={'red'}>
{'新增'} {getList('startFiring').length}
</Tag>
),
},
{
key: 'updatedFiring',
label: (
<Tag color='yellow'>
{'重复'} {getList('updatedFiring').length}
</Tag>
),
},
{
key: 'resolved',
label: (
<Tag color='green'>
{'已解决'} {getList('resolved').length}
</Tag>
),
},
{
key: 'all',
label: (
<Tag color='blue'>
{'当前遗留'} {getList('all').length}
</Tag>
),
},
]
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 (
<div className="flex justify-between w-full" style={style} ref={rowRef}>
<div className="text-xs grow">
<div>{data[index].anormalReason}</div>
<div>
<span className="text-gray-400"></span>
{data[index].serviceName}
</div>
<div className="text-ellipsis text-wrap">
<span className="text-gray-400"></span>
{data[index].endpoint}
</div>
<div className="text-ellipsis text-wrap">
<span className="text-gray-400"></span>
{dayjs(data[index].timestamp).format('YYYY-MM-DD HH:mm:ss')}
</div>
<div className="text-ellipsis text-wrap">
<span className="text-gray-400"></span>
{data[index].anormalMsg}
</div>
</div>
<div className="grow-0">
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
right: '20%',
}}
>
<Tag>{anormalTypesOptions[data[index].anormalType].label}</Tag>
</div>
</div>
</div>
)
}
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 (
<div className="h-full max-h-[400px]">
<div
className="flex items-start justify-between"
style={{ flexDirection: 'column', marginBottom: '0px' }}
>
{/* <div>
<Select
className='w-full'
defaultValue={anormalType}
onSelect={(value) => { setAnormalType(value) }}
items={options}
allowSearch={false}
bgClassName='bg-gray-50'
/>
</div> */}
<div className='flex items-center px-3 border-b-[0.5px] border-divider-subtle'>
{
anormalStatusOptions.map(tab => (
<div
key={tab.key}
className={cn(
'relative mr-4 pt-1 pb-2 system-sm-medium cursor-pointer',
anormalStatus === tab.key
? 'text-text-primary after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:bg-util-colors-blue-brand-blue-brand-600'
: 'text-text-tertiary',
)}
onClick={() => setAnormalStatus(tab.key)}
>
{tab.label}
</div>
))
}
</div>
</div>
<div
onClick={() => {
setScroll(!scroll)
}}
key={key}
style={{ width: '100%' }}
>
<div
style={
scroll && currentList.length
? {
border: '2px solid #7B9DFF',
paddingLeft: '2px',
paddingTop: '2px',
}
: {
border: '2px solid transparent',
pointerEvents: 'none',
paddingLeft: '2px',
paddingTop: '2px',
}
}
>
{currentList.length ? (
<List
height={200}
width={'100%'}
itemSize={getRowHeight}
itemData={getList(anormalStatus)}
itemCount={getList(anormalStatus).length}
ref={listRef}
>
{Row}
</List>
) : (
<div className='flex flex-col items-center'>
<div className='mb-1 text-[13px] font-medium text-text-primary leading-[18px]'>
</div>
{/* <div className='text-[13px] text-text-tertiary leading-[18px]'>
{t(`tools.addToolModal.${searchParams.get('category') === 'workflow' ? 'emptyTip' : 'emptyTipCustom'}`)}
</div> */}
</div>
)}
</div>
</div>
</div>
)
}
export default React.memo(AlertList)

@ -0,0 +1,239 @@
import React, { memo, useEffect, useRef, useState } from 'react'
import ReactECharts from 'echarts-for-react'
import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs'
export const adjustAlpha = (color: string, alpha: number) => {
const rgba = color.match(/\d+/g)
return `rgba(${rgba[0]}, ${rgba[1]}, ${rgba[2]}, ${alpha})`
}
const formatTimeUTC = (value: number) => {
return dayjs(value * 1000).format('HH:mm:ss')
}
const formatDay = (value: number) => {
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
}
type LineChartData = {
[key: string]: number;
}
type LineChartType = 'cpu' | 'network' | 'memory'
type LineChartProps = {
data: LineChartData
type: LineChartType
}
const DelayLineChartTitleMap: Record<LineChartType, string> = {
cpu: 'cpu指标',
network: '网络时延',
memory: '内存占用',
}
const MetricsLineChartColor = {
network: 'rgba(212, 164, 235, 1)',
memory: 'rgba(212, 164, 235, 1)',
cpu: 'rgba(55, 162,235, 1)',
}
export const YValueMinInterval = {
network: 0.01,
memory: 0.01,
cpu: 0.01,
}
const LineChart = ({ type, data }: LineChartProps) => {
const { t } = useTranslation()
const chartRef = useRef(null)
const convertYValue = (value: number) => {
switch (type) {
case 'network':
if (value > 0 && value < 0.01)
return '< 0.01 s'
return `${value}s`
case 'memory':
if (value > 0 && value < 0.01)
return '< 0.01 bytes'
return `${value}bytes`
case 'cpu':
if (value > 0 && value < 0.0001)
return '< 0.01%'
return `${Number.parseFloat((value * 100).toFixed(2))}%`
// case 'tps':
// if (value > 0 && value < 0.01)
// return `< 0.01${t('units.timesPerMinute')}`
// return Number.parseFloat((Math.floor(value * 100) / 100).toString()) + t('units.timesPerMinute')
}
}
const [option, setOption] = useState<any>({
title: {},
tooltip: {
trigger: 'item',
confine: true,
enterable: true,
// alwaysShowContent: true,
axisPointer: {
type: 'cross',
label: {
formatter(params) {
// 自定义格式化函数params.value 是轴上指示的值
const { axisDimension, value } = params
if (axisDimension === 'y')
return convertYValue(value)
else
return formatDay(value * 1000)
// return `自定义格式: ${params.value}`;
},
},
},
formatter: (params) => {
let result = `<div class="rgb(102, 102, 102)">${formatDay(params.data[0] * 1000)}<br/></div>
<div class="overflow-hidden w-full " >`
result += `<div class="flex flex-row items-center justify-between">
<div class="flex flex-row items-center flex-nowrap flex-shrink flex-1 whitespace-normal break-words">
<div class=" my-2 mr-2 rounded-full w-3 h-3 flex-grow-0 flex-shrink-0" style="background:${params.color}"></div>
<div class="flex-1">${params.seriesName}</div>
</div>
<span class="font-bold flex-shrink-0 ml-2">${convertYValue(params.data[1])}</span>
</div>`
// params.forEach((param) => {
// result += `<div class="flex flex-row items-center justify-between">
// <div class="flex flex-row items-center flex-nowrap flex-shrink w-0 flex-1 whitespace-normal break-words">
// <div class=" my-2 mr-2 rounded-full w-3 h-3 flex-grow-0 flex-shrink-0" style="background:${param.color}"></div>
// <div class="flex-1 w-0">${param.seriesName}</div>
// </div>
// <span class="font-bold flex-shrink-0 ">${convertTime(param.data[1], 'ms', 2)} ms</span>
// </div>`
// })
// result+="</div>"
return result
},
},
backgroundColor: 'rgba(0,0,0,0)',
legend: {
type: 'scroll',
data: [],
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '4%',
containLabel: true,
},
// toolbox: {
// feature: {
// saveAsImage: {},
// },
// },
xAxis: {
type: 'time',
boundaryGap: false,
axisPointer: {
type: 'line',
// snap: true
interval: 1,
},
axisLabel: {
hideOverlap: true,
formatter(value) {
return formatTimeUTC(value)
},
},
// axisLine: {
// lineStyle: {
// color: '#000000', // 设置 x 轴刻度线颜色
// },
// },
// axisTick: {
// lineStyle: {
// color: '#000000', // 设置 x 轴刻度线颜色
// },
// },
},
yAxis: {
type: 'value',
minInterval: YValueMinInterval[type],
min: 0,
axisLabel: {
formatter(value: number) {
if(type === 'cpu')
return Number.parseFloat((value * 100).toFixed(2))
return value
},
},
},
series: [],
toolbox: {
show: false, // 隐藏 toolbox
},
brush: {
toolbox: ['lineX'],
brushStyle: {
borderWidth: 1,
color: 'rgba(120,140,180,0.3)',
borderColor: 'rgba(0,0,0,0.5)',
},
},
})
// // 处理缺少数据的时间点并补0
// const fillMissingData = () => {
// const filledData = []
// const { startTime, endTime } = timeRange
// const step = getStep(startTime, endTime)
// for (let time = startTime; time <= endTime; time += step) {
// filledData.push({
// timestamp: time, // 用于显示在图例中
// value: data[time] || 0,
// })
// }
// return filledData
// }
useEffect(() => {
if (data) {
// const filledData = fillMissingData()
setOption({
...option,
xAxis: {
type: 'time',
boundaryGap: false,
axisPointer: {
type: 'line',
interval: 0,
},
axisLabel: {
formatter(value) {
return formatTimeUTC(value)
},
hideOverlap: true,
},
// min: timeRange.startTime / 1000,
// max: timeRange.endTime / 1000,
},
series: [
{
data: Object.entries(data).map(([key, value]) => [Number(key) / 1000, value]),
type: 'line',
smooth: true,
name: DelayLineChartTitleMap[type],
color: MetricsLineChartColor[type],
areaStyle: {
color: adjustAlpha(MetricsLineChartColor[type], 0.3), // 设置区域填充颜色
},
},
],
})
}
}, [data])
return (
<ReactECharts
ref={chartRef}
option={option}
style={{ height: '200px', width: '100%' }}
/>
)
}
export default memo(LineChart)

@ -0,0 +1,39 @@
import React, { useState } from 'react'
import { Table } from 'antd'
import dayjs from 'dayjs'
const LogContent = (props) => {
console.log(props)
const { sources, logContents } = props
const [currentSource, setCurrentSource] = useState()
const column = [
{
title: 'Date',
dataIndex: 'timestamp',
customWidth: 150,
render: ({ value }) => {
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
},
},
{
title: 'Massage',
dataIndex: 'body',
},
]
return (
<>
<div className="flex flex-row items-center">
{/* <span className="text-nowrap">Source</span> */}
{/* <Select
options={sources?.map(item => ({ label: item, value: item }))}
value={currentSource}
onChange={value => setCurrentSource(value)}
/> */}
</div>
{/* <div className="flex-grow flex-shrink overflow-hidden"></div> */}
{logContents?.contents && <Table scroll={{ y: 500 }} columns={column} dataSource={logContents.contents} />}
</>
)
}
export default LogContent

@ -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 }
}

@ -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 (
<ReactFlow
fitView
nodes={nodes}
edges={edges}
edgeTypes={edgeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={memoNodeTypes}
ref={reactFlowInstance}
minZoom={0.1} // 设置最小缩放
maxZoom={2} // 设置最大缩放
>
<Background
gap={[14, 14]}
size={2}
className="bg-workflow-canvas-workflow-bg"
color='var(--color-workflow-canvas-workflow-dot-color)'
/>
</ReactFlow>
)
}
function FlowWithProvider(props) {
return (
<ReactFlowProvider>
<svg style={{ position: 'absolute', top: 0, left: 0 }}>
<defs>
<marker
id="arrowhead"
viewBox="0 0 74.4539794921875 67"
refX="37.227"
refY="33.5"
markerWidth="16"
markerHeight="16"
>
<path
d="M45.4542 4.75L73.167 52.75C76.8236 59.0833 72.2529 67 64.9398 67L9.51418 67C2.20107 67 -2.36962 59.0833 1.28693 52.75L28.9997 4.75C32.6563 -1.58334 41.7977 -1.58334 45.4542 4.75Z"
fill="#6293ff"
/>
</marker>
</defs>
</svg>
<LayoutFlow {...props} />
</ReactFlowProvider>
)
}
export default FlowWithProvider

@ -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 (
<>
<path
id={id}
style={style}
className="react-flow__edge-path loop"
d={edgePath}
markerEnd="url(#arrowhead)"
/>
</>
)
}
export default CustomSelfLoopEdge

@ -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 (
<div className="rounded-[15px]">
<Handle
type="target"
position={Position.Left}
isConnectable={isConnectable}
className="invisible"
/>
<div
className="shadow-xs border-[2px] border-transparen w-[200px] min-h-[60px] p-2 rounded-[15px] overflow-hidden bg-[#fcfdff]/80"
>
<div className="absolute top-0 left-0">
</div>
<div className="text-center text-lg pt-2 px-2">
{data.label}
<div className="text-xs text-gray-500 text-left break-all">{data.endpoint}</div>
</div>
</div>
<Handle
type="source"
position={Position.Right}
id="b"
isConnectable={isConnectable}
className="invisible"
/>
</div>
)
})
export default ServiceNode

@ -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 && <TextGeneration isWorkflow isInstalledApp installedAppInfo={installedApp} apiKey={apiKeysList.data[0].token}/>
}
const WorkflowRunContainer = () => {
const appDetail = useAppStore(state => state.appDetail)!
const [installedApp, setInstalledApp] = useState<InstalledApp>()
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 (
<div className='flex h-full items-center'>
<Loading type='area' />
</div>
)
}
return (
<div className='h-full py-2 pl-0 pr-2 sm:p-2'>
{installedApp.app.mode === 'workflow' && (
<TextGenerationApp installedApp={installedApp}/>
)}
</div>
)
}
export default React.memo(WorkflowRunContainer)

@ -0,0 +1,15 @@
import { memo } from 'react'
import { Markdown } from '@/app/components/base/markdown'
type LLMOutputsProps = {
text: string;
}
const LLMOutputs = ({ text }: LLMOutputsProps) => {
return (
<div className="w-full overflow-hidden p-3">
<div className='px-4 py-3 bg-[#D1E9FF]/50 rounded-2xl text-sm text-gray-900'>
<Markdown content={text || ''} />
</div>
</div>
)
}
export default memo(LLMOutputs)

@ -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<Props> = ({
className,
nodeInfo,
inMessage = false,
hideInfo = false,
hideProcessDetail,
onShowIterationDetail,
onShowRetryDetail,
onShowAgentOrToolLog,
notShowIterationNav,
}) => {
const [collapseState, doSetCollapseState] = useState<boolean>(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 (
<div className={cn('px-2 py-1', className)}>
<div className='group transition-all bg-background-default border border-components-panel-border rounded-[10px] shadow-xs hover:shadow-md'>
<div
className={cn(
'flex items-center pl-1 pr-3 cursor-pointer',
hideInfo ? 'py-2' : 'py-1.5',
!collapseState && (hideInfo ? '!pb-1' : '!pb-1.5'),
)}
onClick={() => setCollapseState(!collapseState)}
>
{!hideProcessDetail && (
<RiArrowRightSLine
className={cn(
'shrink-0 w-4 h-4 mr-1 text-text-quaternary transition-all group-hover:text-text-tertiary',
!collapseState && 'rotate-90',
)}
/>
)}
<BlockIcon size={inMessage ? 'xs' : 'sm'} className={cn('shrink-0 mr-2', inMessage && '!mr-1')} type={nodeInfo.node_type} toolIcon={nodeInfo.extras?.icon || nodeInfo.extras} />
<div className={cn(
'grow text-text-secondary system-xs-semibold-uppercase truncate',
hideInfo && '!text-xs',
)} title={nodeInfo.title}>{nodeInfo.title}</div>
{nodeInfo.status !== 'running' && !hideInfo && (
<div className='shrink-0 text-text-tertiary system-xs-regular'>{nodeInfo.execution_metadata?.total_tokens ? `${getTokenCount(nodeInfo.execution_metadata?.total_tokens || 0)} tokens · ` : ''}{`${getTime(nodeInfo.elapsed_time || 0)}`}</div>
)}
{nodeInfo.status === 'succeeded' && (
<RiCheckboxCircleFill className='shrink-0 ml-2 w-3.5 h-3.5 text-text-success' />
)}
{nodeInfo.status === 'failed' && (
<RiErrorWarningLine className='shrink-0 ml-2 w-3.5 h-3.5 text-text-warning' />
)}
{nodeInfo.status === 'stopped' && (
<RiAlertFill className={cn('shrink-0 ml-2 w-4 h-4 text-text-warning-secondary', inMessage && 'w-3.5 h-3.5')} />
)}
{nodeInfo.status === 'exception' && (
<RiAlertFill className={cn('shrink-0 ml-2 w-4 h-4 text-text-warning-secondary', inMessage && 'w-3.5 h-3.5')} />
)}
{nodeInfo.status === 'running' && (
<div className='shrink-0 flex items-center text-text-accent text-[13px] leading-[16px] font-medium'>
<span className='mr-2 text-xs font-normal'>Running</span>
<RiLoader2Line className='w-3.5 h-3.5 animate-spin' />
</div>
)}
</div>
{
nodeInfo.node_type === BlockEnum.LLM && nodeInfo.outputs?.text && <LLMOutputs text={nodeInfo.outputs?.text} />
}
{nodeInfo.node_type === BlockEnum.Tool && (nodeInfo.outputs?.text)
&& <div className='px-4 py-2'><DataDisplay data={nodeInfo.outputs.text} /></div>}
{!collapseState && !hideProcessDetail && (
<div className='px-1 pb-1'>
{/* The nav to the iteration detail */}
{isIterationNode && !notShowIterationNav && onShowIterationDetail && (
<IterationLogTrigger
nodeInfo={nodeInfo}
onShowIterationResultList={onShowIterationDetail}
/>
)}
{isRetryNode && onShowRetryDetail && (
<RetryLogTrigger
nodeInfo={nodeInfo}
onShowRetryResultList={onShowRetryDetail}
/>
)}
{
(isAgentNode || isToolNode) && onShowAgentOrToolLog && (
<AgentLogTrigger
nodeInfo={nodeInfo}
onShowAgentOrToolLog={onShowAgentOrToolLog}
/>
)
}
<div className={cn('mb-1', hideInfo && '!px-2 !py-0.5')}>
{(nodeInfo.status === 'stopped') && (
<StatusContainer status='stopped'>
{t('workflow.tracing.stopBy', { user: nodeInfo.created_by ? nodeInfo.created_by.name : 'N/A' })}
</StatusContainer>
)}
{(nodeInfo.status === 'exception') && (
<StatusContainer status='stopped'>
{nodeInfo.error}
<a
href='https://docs.dify.ai/guides/workflow/error-handling/error-type'
target='_blank'
className='text-text-accent'
>
{t('workflow.common.learnMore')}
</a>
</StatusContainer>
)}
{nodeInfo.status === 'failed' && (
<StatusContainer status='failed'>
{nodeInfo.error}
</StatusContainer>
)}
{nodeInfo.status === 'retry' && (
<StatusContainer status='failed'>
{nodeInfo.error}
</StatusContainer>
)}
</div>
{nodeInfo.inputs && (
<div className={cn('mb-1')}>
<CodeEditor
readOnly
title={<div>{t('workflow.common.input').toLocaleUpperCase()}</div>}
language={CodeLanguage.json}
value={nodeInfo.inputs}
isJSONStringifyBeauty
/>
</div>
)}
{nodeInfo.process_data && (
<div className={cn('mb-1')}>
<CodeEditor
readOnly
title={<div>{t('workflow.common.processData').toLocaleUpperCase()}</div>}
language={CodeLanguage.json}
value={nodeInfo.process_data}
isJSONStringifyBeauty
/>
</div>
)}
{nodeInfo.outputs && (
<div>
<CodeEditor
readOnly
title={<div>{t('workflow.common.output').toLocaleUpperCase()}</div>}
language={CodeLanguage.json}
value={nodeInfo.outputs}
isJSONStringifyBeauty
tip={<ErrorHandleTip type={nodeInfo.execution_metadata?.error_strategy} />}
/>
</div>
)}
</div>
)}
</div>
</div>
)
}
export default NodePanel

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.66699 1.33366C3.66699 0.965469 3.36852 0.666992 3.00033 0.666992C2.63214 0.666992 2.33366 0.965469 2.33366 1.33366V2.33366H1.33366C0.965469 2.33366 0.666992 2.63214 0.666992 3.00033C0.666992 3.36852 0.965469 3.66699 1.33366 3.66699H2.33366V4.66699C2.33366 5.03518 2.63214 5.33366 3.00033 5.33366C3.36852 5.33366 3.66699 5.03518 3.66699 4.66699V3.66699H4.66699C5.03518 3.66699 5.33366 3.36852 5.33366 3.00033C5.33366 2.63214 5.03518 2.33366 4.66699 2.33366H3.66699V1.33366Z" fill="#444CE7"/>
<path d="M3.66699 11.3337C3.66699 10.9655 3.36852 10.667 3.00033 10.667C2.63214 10.667 2.33366 10.9655 2.33366 11.3337V12.3337H1.33366C0.965469 12.3337 0.666992 12.6321 0.666992 13.0003C0.666992 13.3685 0.965469 13.667 1.33366 13.667H2.33366V14.667C2.33366 15.0352 2.63214 15.3337 3.00033 15.3337C3.36852 15.3337 3.66699 15.0352 3.66699 14.667V13.667H4.66699C5.03518 13.667 5.33366 13.3685 5.33366 13.0003C5.33366 12.6321 5.03518 12.3337 4.66699 12.3337H3.66699V11.3337Z" fill="#444CE7"/>
<path d="M9.28922 1.76101C9.1902 1.50354 8.94284 1.33366 8.66699 1.33366C8.39114 1.33366 8.14378 1.50354 8.04476 1.76101L6.88864 4.76691C6.68837 5.28761 6.62544 5.43766 6.53936 5.55872C6.45299 5.68019 6.34686 5.78632 6.22539 5.87269C6.10432 5.95878 5.95428 6.02171 5.43358 6.22198L2.42767 7.37809C2.17021 7.47712 2.00033 7.72448 2.00033 8.00033C2.00033 8.27617 2.17021 8.52353 2.42767 8.62256L5.43358 9.77867C5.95428 9.97894 6.10432 10.0419 6.22539 10.128C6.34686 10.2143 6.45299 10.3205 6.53936 10.4419C6.62544 10.563 6.68837 10.713 6.88864 11.2337L8.04476 14.2396C8.14379 14.4971 8.39114 14.667 8.66699 14.667C8.94284 14.667 9.1902 14.4971 9.28922 14.2396L10.4453 11.2337C10.6456 10.713 10.7085 10.563 10.7946 10.4419C10.881 10.3205 10.9871 10.2143 11.1086 10.128C11.2297 10.0419 11.3797 9.97894 11.9004 9.77867L14.9063 8.62256C15.1638 8.52353 15.3337 8.27617 15.3337 8.00033C15.3337 7.72448 15.1638 7.47712 14.9063 7.37809L11.9004 6.22198C11.3797 6.02171 11.2297 5.95878 11.1086 5.87269C10.9871 5.78632 10.881 5.68019 10.7946 5.55872C10.7085 5.43766 10.6456 5.28761 10.4453 4.76691L9.28922 1.76101Z" fill="#444CE7"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@ -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<string, any>
}
type Task = {
id: number
status: TaskStatus
params: TaskParam
}
export type IMainProps = {
isInstalledApp?: boolean
installedAppInfo?: InstalledApp
isWorkflow?: boolean
apiKey: string
}
const TextGeneration: FC<IMainProps> = ({
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<string>(['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<Record<string, any>>({})
const inputsRef = useRef(inputs)
const setInputs = useCallback((newInputs: Record<string, any>) => {
doSetInputs(newInputs)
inputsRef.current = newInputs
}, [])
const [appId, setAppId] = useState<string>('')
const [siteInfo, setSiteInfo] = useState<SiteInfo | null>(null)
const [canReplaceLogo, setCanReplaceLogo] = useState<boolean>(false)
const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null)
const [moreLikeThisConfig, setMoreLikeThisConfig] = useState<MoreLikeThisConfig | null>(null)
const [textToSpeechConfig, setTextToSpeechConfig] = useState<TextToSpeechConfig | null>(null)
// save message
const [savedMessages, setSavedMessages] = useState<SavedMessage[]>([])
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<VisionSettings>({
enabled: false,
number_limits: 2,
detail: Resolution.low,
transfer_methods: [TransferMethod.local_file],
})
const [completionFiles, setCompletionFiles] = useState<VisionFile[]>([])
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<Task[]>([])
const allTaskListRef = useRef<Task[]>([])
const getLatestTaskList = () => allTaskListRef.current
const setAllTaskList = (taskList: Task[]) => {
doSetAllTaskList(taskList)
allTaskListRef.current = taskList
}
const pendingTaskList = allTaskList.filter(task => task.status === TaskStatus.pending)
const noPendingTask = pendingTaskList.length === 0
const showTaskList = allTaskList.filter(task => task.status !== TaskStatus.pending)
const [currGroupNum, doSetCurrGroupNum] = useState(0)
const currGroupNumRef = useRef(0)
const setCurrGroupNum = (num: number) => {
doSetCurrGroupNum(num)
currGroupNumRef.current = num
}
const getCurrGroupNum = () => {
return currGroupNumRef.current
}
const allSuccessTaskList = allTaskList.filter(task => task.status === TaskStatus.completed)
const allFailedTaskList = allTaskList.filter(task => task.status === TaskStatus.failed)
const allTasksFinished = allTaskList.every(task => task.status === TaskStatus.completed)
const allTasksRun = allTaskList.every(task => [TaskStatus.completed, TaskStatus.failed].includes(task.status))
const [batchCompletionRes, doSetBatchCompletionRes] = useState<Record<string, string>>({})
const batchCompletionResRef = useRef<Record<string, string>>({})
const setBatchCompletionRes = (res: Record<string, string>) => {
doSetBatchCompletionRes(res)
batchCompletionResRef.current = res
}
const getBatchCompletionRes = () => batchCompletionResRef.current
const exportRes = allTaskList.map((task) => {
const batchCompletionResLatest = getBatchCompletionRes()
const res: Record<string, string> = {}
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<string, string> = {}
if (varLen > 0) {
item.slice(0, varLen).forEach((input, index) => {
inputs[promptConfig?.prompt_variables[index].key as string] = input
})
}
return {
id: i + 1,
status: i < 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<HTMLDivElement>(null)
useClickAway(() => {
hideResSidebar()
}, resRef)
const renderRes = (task?: Task) => (<Res
key={task?.id}
isWorkflow={isWorkflow}
isCallBatchAPI={isCallBatchAPI}
isPC={isPC}
isMobile={isMobile}
isInstalledApp={isInstalledApp}
installedAppInfo={installedAppInfo}
isError={task?.status === TaskStatus.failed}
promptConfig={promptConfig}
moreLikeThisEnabled={!!moreLikeThisConfig?.enabled}
inputs={isCallBatchAPI ? (task as Task).params.inputs : inputs}
controlSend={controlSend}
controlRetry={task?.status === TaskStatus.failed ? controlRetry : 0}
controlStopResponding={controlStopResponding}
onShowRes={showResSidebar}
handleSaveMessage={handleSaveMessage}
taskId={task?.id}
onCompleted={handleCompleted}
visionConfig={visionConfig}
completionFiles={completionFiles}
isShowTextToSpeech={!!textToSpeechConfig?.enabled}
siteInfo={siteInfo}
apiKey={apiKey}
/>)
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 = (
<div
ref={resRef}
className={
cn(
'flex flex-col h-full shrink-0',
isPC ? 'px-10 py-8' : 'bg-gray-50',
isTablet && 'p-6', isMobile && 'p-4')
}
>
<>
<div className='flex items-center justify-between shrink-0'>
<div className='flex items-center space-x-3'>
<div className={s.starIcon}></div>
<div className='text-lg font-semibold text-gray-800'>{t('share.generation.title')}</div>
</div>
<div className='flex items-center space-x-2'>
{allFailedTaskList.length > 0 && (
<div className='flex items-center'>
<RiErrorWarningFill className='w-4 h-4 text-[#D92D20]' />
<div className='ml-1 text-[#D92D20]'>{t('share.generation.batchFailed.info', { num: allFailedTaskList.length })}</div>
<Button
variant='primary'
className='ml-2'
onClick={handleRetryAllFailedTask}
>{t('share.generation.batchFailed.retry')}</Button>
<div className='mx-3 w-[1px] h-3.5 bg-gray-200'></div>
</div>
)}
{allSuccessTaskList.length > 0 && (
<ResDownload
isMobile={isMobile}
values={exportRes}
/>
)}
{!isPC && (
<div
className='flex items-center justify-center cursor-pointer'
onClick={hideResSidebar}
>
<XMarkIcon className='w-4 h-4 text-gray-800' />
</div>
)}
</div>
</div>
<div className='overflow-y-auto grow'>
{!isCallBatchAPI ? renderRes() : renderBatchRes()}
{!noPendingTask && (
<div className='mt-4'>
<Loading type='area' />
</div>
)}
</div>
</>
</div>
)
if (!appId || !siteInfo || !promptConfig) {
return (
<div className='flex items-center h-screen'>
<Loading type='app' />
</div>)
}
return (
<>
<div className={cn(
isPC && 'flex',
isInstalledApp ? s.installedApp : 'h-screen',
'bg-gray-50',
)}>
{/* Left */}
<div className={cn(
isPC ? 'w-[600px] max-w-[50%] p-8' : 'p-4',
isInstalledApp && 'rounded-l-2xl',
'shrink-0 relative flex flex-col pb-10 h-full border-r border-gray-100 bg-white',
)}>
<div className='mb-6'>
<div className='flex items-center justify-between'>
<div className='flex items-center space-x-3'>
<AppIcon
size="small"
iconType={siteInfo.icon_type}
icon={siteInfo.icon}
background={siteInfo.icon_background || appDefaultIconBackground}
imageUrl={siteInfo.icon_url}
/>
<div className='text-lg font-semibold text-gray-800'>{siteInfo.title}</div>
</div>
{!isPC && (
<Button
className='shrink-0 ml-2'
onClick={showResSidebar}
>
<div className='flex items-center space-x-2 text-primary-600 text-[13px] font-medium'>
<div className={s.starIcon}></div>
<span>{t('share.generation.title')}</span>
</div>
</Button>
)}
</div>
{siteInfo.description && (
<div className='mt-2 text-xs text-gray-500'>{siteInfo.description}</div>
)}
</div>
{/* <TabHeader
items={[
{ id: 'create', name: t('share.generation.tabs.create') },
{ id: 'batch', name: t('share.generation.tabs.batch') },
...(!isWorkflow
? [{
id: 'saved',
name: t('share.generation.tabs.saved'),
isRight: true,
extra: savedMessages.length > 0
? (
<div className='ml-1 flex items-center h-5 px-1.5 rounded-md border border-gray-200 text-gray-500 text-xs font-medium'>
{savedMessages.length}
</div>
)
: null,
}]
: []),
]}
value={currentTab}
onChange={setCurrentTab}
/> */}
<div className='h-20 overflow-y-auto grow'>
{/* <div className={cn(currentTab === 'create' ? 'block' : 'hidden')}> */}
<RunOnce
siteInfo={siteInfo}
inputs={inputs}
inputsRef={inputsRef}
onInputsChange={setInputs}
promptConfig={promptConfig}
onSend={handleSend}
visionConfig={visionConfig}
onVisionFilesChange={setCompletionFiles}
/>
{/* </div> */}
{/* <div className={cn(isInBatchTab ? 'block' : 'hidden')}>
<RunBatch
vars={promptConfig.prompt_variables}
onSend={handleRunBatch}
isAllFinished={allTasksRun}
/>
</div>
{currentTab === 'saved' && (
<SavedItems
className='mt-4'
isShowTextToSpeech={textToSpeechConfig?.enabled}
list={savedMessages}
onRemove={handleRemoveSavedMessage}
onStartCreateContent={() => setCurrentTab('create')}
/>
)} */}
</div>
{/* copyright */}
<div className={cn(
isInstalledApp ? 'left-[248px]' : 'left-8',
'fixed bottom-4 flex space-x-2 text-gray-400 font-normal text-xs',
)}>
<div className="">© {siteInfo.copyright || siteInfo.title} {(new Date()).getFullYear()}</div>
{siteInfo.privacy_policy && (
<>
<div>·</div>
<div>{t('share.chat.privacyPolicyLeft')}
<a
className='text-gray-500 px-1'
href={siteInfo.privacy_policy}
target='_blank' rel='noopener noreferrer'>{t('share.chat.privacyPolicyMiddle')}</a>
{t('share.chat.privacyPolicyRight')}
</div>
</>
)}
</div>
</div>
{/* Result */}
<div
className={resWrapClassNames}
style={{
background: (!isPC && isShowResSidebar) ? 'rgba(35, 56, 118, 0.2)' : 'none',
}}
>
{renderResWrap}
</div>
</div>
</>
)
}
export default TextGeneration

@ -0,0 +1,26 @@
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
const StarIcon = (
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.50033 48.3337V36.667M7.50033 13.3337V1.66699M1.66699 7.50033H13.3337M1.66699 42.5003H13.3337M27.3337 4.00032L23.2872 14.521C22.6292 16.2319 22.3002 17.0873 21.7886 17.8069C21.3351 18.4446 20.7779 19.0018 20.1402 19.4552C19.4206 19.9669 18.5652 20.2959 16.8543 20.9539L6.33366 25.0003L16.8543 29.0467C18.5652 29.7048 19.4206 30.0338 20.1402 30.5454C20.7779 30.9989 21.3351 31.5561 21.7886 32.1938C22.3002 32.9133 22.6292 33.7688 23.2872 35.4796L27.3337 46.0003L31.3801 35.4796C32.0381 33.7688 32.3671 32.9133 32.8788 32.1938C33.3322 31.5561 33.8894 30.9989 34.5271 30.5454C35.2467 30.0338 36.1021 29.7048 37.813 29.0467L48.3337 25.0003L37.813 20.9539C36.1021 20.2959 35.2467 19.9669 34.5271 19.4552C33.8894 19.0018 33.3322 18.4446 32.8788 17.8069C32.3671 17.0873 32.0381 16.2319 31.3801 14.521L27.3337 4.00032Z" stroke="#EAECF0" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
export type INoDataProps = {}
const NoData: FC<INoDataProps> = () => {
const { t } = useTranslation()
return (
<div className='flex flex-col h-full w-full justify-center items-center'>
{StarIcon}
<div
className='mt-3 text-gray-300 text-xs leading-3'
>
{t('share.generation.noData')}
</div>
</div>
)
}
export default React.memo(NoData)

@ -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<IResultProps> = ({
content,
showFeedback,
feedback,
onFeedback,
}) => {
return (
<div className='basis-3/4 h-max'>
<Header result={content} showFeedback={showFeedback} feedback={feedback} onFeedback={onFeedback} />
<div
className='mt-4 w-full flex text-sm leading-5 overflow-scroll font-normal text-gray-900'
style={{
maxHeight: '70vh',
}}
dangerouslySetInnerHTML={{
__html: format(content),
}}
></div>
</div>
)
}
export default React.memo(Result)

@ -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<IResultHeaderProps> = ({
feedback,
showFeedback,
onFeedback,
result,
}) => {
const { t } = useTranslation()
return (
<div className='flex w-full justify-between items-center '>
<div className='text-gray-800 text-2xl leading-4 font-normal'>{t('share.generation.resultTitle')}</div>
<div className='flex items-center space-x-2'>
<Button
className='h-7 p-[2px] pr-2'
onClick={() => {
copy(result)
Toast.notify({ type: 'success', message: 'copied' })
}}
>
<>
<ClipboardDocumentIcon className='text-gray-500 w-4 h-3 mr-1' />
<span className='text-gray-500 text-xs leading-3'>{t('share.generation.copy')}</span>
</>
</Button>
{showFeedback && feedback.rating && feedback.rating === 'like' && (
<Tooltip
popupContent="Undo Great Rating"
>
<div
onClick={() => {
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'>
<HandThumbUpIcon width={16} height={16} />
</div>
</Tooltip>
)}
{showFeedback && feedback.rating && feedback.rating === 'dislike' && (
<Tooltip
popupContent="Undo Undesirable Response"
>
<div
onClick={() => {
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'>
<HandThumbDownIcon width={16} height={16} />
</div>
</Tooltip>
)}
{showFeedback && !feedback.rating && (
<div className='flex rounded-lg border border-gray-200 p-[1px] space-x-1'>
<Tooltip
popupContent="Great Rating"
needsDelay={false}
>
<div
onClick={() => {
onFeedback({
rating: 'like',
})
}}
className='flex w-6 h-6 items-center justify-center rounded-md cursor-pointer hover:bg-gray-100'>
<HandThumbUpIcon width={16} height={16} />
</div>
</Tooltip>
<Tooltip
popupContent="Undesirable Response"
needsDelay={false}
>
<div
onClick={() => {
onFeedback({
rating: 'dislike',
})
}}
className='flex w-6 h-6 items-center justify-center rounded-md cursor-pointer hover:bg-gray-100'>
<HandThumbDownIcon width={16} height={16} />
</div>
</Tooltip>
</div>
)}
</div>
</div>
)
}
export default React.memo(Header)

@ -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<string, any>
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<ModerationService>
visionConfig: VisionSettings
completionFiles: VisionFile[]
siteInfo: SiteInfo | null
apiKey: string
}
const Result: FC<IResultProps> = ({
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<any>('')
const completionResRef = useRef<any>()
const setCompletionRes = (res: any) => {
completionResRef.current = res
doSetCompletionRes(res)
}
const getCompletionRes = () => completionResRef.current
const [workflowProcessData, doSetWorkflowProcessData] = useState<WorkflowProcess>()
const workflowProcessDataRef = useRef<WorkflowProcess>()
const setWorkflowProcessData = (data: WorkflowProcess) => {
workflowProcessDataRef.current = data
doSetWorkflowProcessData(data)
}
const getWorkflowProcessData = () => workflowProcessDataRef.current
const { notify } = Toast
const isNoData = !completionRes
const [messageId, setMessageId] = useState<string | null>(null)
const [feedback, setFeedback] = useState<FeedbackType>({
rating: null,
})
const handleFeedback = async (feedback: FeedbackType) => {
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } }, isInstalledApp, installedAppInfo?.id)
setFeedback(feedback)
}
const logError = (message: string) => {
notify({ type: 'error', message })
}
const checkCanSend = () => {
// batch will check outer
if (isCallBatchAPI)
return true
const prompt_variables = promptConfig?.prompt_variables
if (!prompt_variables || prompt_variables?.length === 0) {
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<string, any> = {
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 = () => (
<TracingPanel
className='bg-background-section-burn'
list={workflowProcessData?.tracing || []}
ifForRun={true}
/>
// <TextGenerationRes
// isWorkflow={isWorkflow}
// workflowProcessData={workflowProcessData}
// className='mt-3'
// isError={isError}
// onRetry={handleSend}
// content={completionRes}
// messageId={messageId}
// isInWebApp
// moreLikeThis={moreLikeThisEnabled}
// onFeedback={handleFeedback}
// feedback={feedback}
// onSave={handleSaveMessage}
// isMobile={isMobile}
// isInstalledApp={isInstalledApp}
// installedAppId={installedAppInfo?.id}
// isLoading={isCallBatchAPI ? (!completionRes && isResponding) : false}
// taskId={isCallBatchAPI ? ((taskId as number) < 10 ? `0${taskId}` : `${taskId}`) : undefined}
// controlClearMoreLikeThis={controlClearMoreLikeThis}
// isShowTextToSpeech={isShowTextToSpeech}
// hideProcessDetail
// siteInfo={siteInfo}
// />
)
return (
<div className={cn(isNoData && !isCallBatchAPI && 'h-full')}>
{
!isCallBatchAPI && isWorkflow && (
(isResponding && !workflowProcessData)
? (
<div className='flex h-full w-full justify-center items-center'>
<Loading type='area' />
</div>
)
: !workflowProcessData
? <NoData />
: renderTextGenerationRes()
)
}
{isCallBatchAPI && (
<div className='mt-2'>
{renderTextGenerationRes()}
</div>
)}
</div>
)
}
export default React.memo(Result)

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

@ -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<Props> = ({
onParsed,
}) => {
const { t } = useTranslation()
const { CSVReader } = useCSVReader()
const [zoneHover, setZoneHover] = useState(false)
return (
<CSVReader
onUploadAccepted={(results: any) => {
onParsed(results.data)
setZoneHover(false)
}}
onDragOver={(event: DragEvent) => {
event.preventDefault()
setZoneHover(true)
}}
onDragLeave={(event: DragEvent) => {
event.preventDefault()
setZoneHover(false)
}}
>
{({
getRootProps,
acceptedFile,
}: any) => (
<>
<div
{...getRootProps()}
className={cn(s.zone, zoneHover && s.zoneHover, acceptedFile ? 'px-6' : 'justify-center border-dashed text-gray-500')}
>
{
acceptedFile
? (
<div className='w-full flex items-center space-x-2'>
<CSVIcon className="shrink-0" />
<div className='flex w-0 grow'>
<span className='max-w-[calc(100%_-_30px)] text-ellipsis whitespace-nowrap overflow-hidden text-gray-800'>{acceptedFile.name.replace(/.csv$/, '')}</span>
<span className='shrink-0 text-gray-500'>.csv</span>
</div>
</div>
)
: (
<div className='flex items-center justify-center space-x-2'>
<CSVIcon className="shrink-0" />
<div className='text-gray-500'>{t('share.generation.csvUploadTitle')}<span className='text-primary-400'>{t('share.generation.browse')}</span></div>
</div>
)}
</div>
</>
)}
</CSVReader>
)
}
export default React.memo(CSVReader)

@ -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;
}

@ -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<IRunBatchProps> = ({
vars,
onSend,
isAllFinished,
}) => {
const { t } = useTranslation()
const [csvData, setCsvData] = React.useState<string[][]>([])
const [isParsed, setIsParsed] = React.useState(false)
const handleParsed = (data: string[][]) => {
setCsvData(data)
// console.log(data)
setIsParsed(true)
}
const handleSend = () => {
onSend(csvData)
}
const Icon = isAllFinished ? PlayIcon : RiLoader2Line
return (
<div className='pt-4'>
<CSVReader onParsed={handleParsed} />
<CSVDownload vars={vars} />
<div className='mt-4 h-[1px] bg-gray-100'></div>
<div className='flex justify-end'>
<Button
variant="primary"
className='mt-4 pl-3 pr-4'
onClick={handleSend}
disabled={!isParsed || !isAllFinished}
>
<Icon className={cn(!isAllFinished && 'animate-spin', 'shrink-0 w-4 h-4 mr-1')} aria-hidden="true" />
<span className='uppercase text-[13px]'>{t('share.generation.run')}</span>
</Button>
</div>
</div>
)
}
export default React.memo(RunBatch)

@ -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<string, string>[]
}
const ResDownload: FC<IResDownloadProps> = ({
isMobile,
values,
}) => {
const { t } = useTranslation()
const { CSVDownloader, Type } = useCSVDownloader()
return (
<CSVDownloader
className="block cursor-pointer"
type={Type.Link}
filename={'result'}
bom={true}
config={{
// delimiter: ';',
}}
data={values}
>
<Button className={cn('space-x-2 bg-white', isMobile ? '!p-0 !w-8 justify-center' : '')}>
<DownloadIcon className='w-4 h-4 text-[#155EEF]' />
{!isMobile && <span className='text-[#155EEF]'>{t('common.operation.download')}</span>}
</Button>
</CSVDownloader>
)
}
export default React.memo(ResDownload)

@ -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<string, any>
inputsRef: React.MutableRefObject<Record<string, any>>
onInputsChange: (inputs: Record<string, any>) => void
onSend: () => void
visionConfig: VisionSettings
onVisionFilesChange: (files: VisionFile[]) => void
}
const RunOnce: FC<IRunOnceProps> = ({
promptConfig,
inputs,
inputsRef,
onInputsChange,
onSend,
visionConfig,
onVisionFilesChange,
}) => {
const { t } = useTranslation()
const onClear = () => {
const newInputs: Record<string, any> = {}
promptConfig.prompt_variables.forEach((item) => {
newInputs[item.key] = ''
})
onInputsChange(newInputs)
}
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
onSend()
}
const handleInputsChange = useCallback((newInputs: Record<string, any>) => {
onInputsChange(newInputs)
inputsRef.current = newInputs
}, [onInputsChange, inputsRef])
return (
<div className="">
<section>
{/* input form */}
<form onSubmit={onSubmit}>
{promptConfig.prompt_variables.map(item => (
<div className='w-full mt-4' key={item.key}>
<label className='text-gray-900 text-sm font-medium'>{item.name}</label>
<div className='mt-2'>
{item.type === 'select' && (
<Select
className='w-full'
defaultValue={inputs[item.key]}
onSelect={(i) => { handleInputsChange({ ...inputsRef.current, [item.key]: i.value }) }}
items={(item.options || []).map(i => ({ name: i, value: i }))}
allowSearch={false}
bgClassName='bg-gray-50'
/>
)}
{item.type === 'string' && (
<input
type="text"
className="block w-full p-2 text-gray-900 border border-gray-300 rounded-lg bg-gray-50 sm:text-xs focus:ring-blue-500 focus:border-blue-500 "
placeholder={`${item.name}${!item.required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
value={inputs[item.key]}
onChange={(e) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }}
maxLength={item.max_length || DEFAULT_VALUE_MAX_LEN}
/>
)}
{item.type === 'paragraph' && (
<Textarea
className='h-[104px] sm:text-xs'
placeholder={`${item.name}${!item.required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
value={inputs[item.key]}
onChange={(e) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }}
/>
)}
{item.type === 'number' && (
<input
type="number"
className="block w-full p-2 text-gray-900 border border-gray-300 rounded-lg bg-gray-50 sm:text-xs focus:ring-blue-500 focus:border-blue-500 "
placeholder={`${item.name}${!item.required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
value={inputs[item.key]}
onChange={(e) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }}
/>
)}
{item.type === 'file' && (
<FileUploaderInAttachmentWrapper
onChange={(files) => { handleInputsChange({ ...inputsRef.current, [item.key]: getProcessedFiles(files)[0] }) }}
fileConfig={{
...item.config,
fileUploadConfig: (visionConfig as any).fileUploadConfig,
}}
/>
)}
{item.type === 'file-list' && (
<FileUploaderInAttachmentWrapper
onChange={(files) => { handleInputsChange({ ...inputsRef.current, [item.key]: getProcessedFiles(files) }) }}
fileConfig={{
...item.config,
fileUploadConfig: (visionConfig as any).fileUploadConfig,
}}
/>
)}
</div>
</div>
))}
{
visionConfig?.enabled && (
<div className="w-full mt-4">
<div className="text-gray-900 text-sm font-medium">{t('common.imageUploader.imageUpload')}</div>
<div className='mt-2'>
<TextGenerationImageUploader
settings={visionConfig}
onFilesChange={files => onVisionFilesChange(files.filter(file => file.progress !== -1).map(fileItem => ({
type: 'image',
transfer_method: fileItem.type,
url: fileItem.url,
upload_file_id: fileItem.fileId,
})))}
/>
</div>
</div>
)
}
{promptConfig.prompt_variables.length > 0 && (
<div className='mt-4 h-[1px] bg-gray-100'></div>
)}
<div className='w-full mt-4'>
<div className="flex items-center justify-between">
<Button
onClick={onClear}
disabled={false}
>
<span className='text-[13px]'>{t('common.operation.clear')}</span>
</Button>
<Button
type='submit'
variant="primary"
disabled={false}
>
<PlayIcon className="shrink-0 w-4 h-4 mr-1" aria-hidden="true" />
<span className='text-[13px]'>{t('share.generation.run')}</span>
</Button>
</div>
</div>
</form>
</section>
</div>
)
}
export default React.memo(RunOnce)

@ -0,0 +1,12 @@
.installedApp {
height: 100%;
border-radius: 16px;
box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
}
.starIcon {
width: 16px;
height: 16px;
background: url(./icons/star.svg) center center no-repeat;
background-size: contain;
}

@ -0,0 +1,53 @@
import { CONVERSATION_ID_INFO } from '../base/chat/constants'
import { fetchAccessToken } from '@/service/share'
export const checkOrSetAccessToken = async () => {
const sharedToken = globalThis.location.pathname.split('/').slice(-1)[0]
const accessToken = localStorage.getItem('token') || JSON.stringify({ [sharedToken]: '' })
let accessTokenJson = { [sharedToken]: '' }
try {
accessTokenJson = JSON.parse(accessToken)
}
catch (e) {
}
if (!accessTokenJson[sharedToken]) {
const res = await fetchAccessToken(sharedToken)
accessTokenJson[sharedToken] = res.access_token
localStorage.setItem('token', JSON.stringify(accessTokenJson))
}
}
export const setAccessToken = async (sharedToken: string, token: string) => {
const accessToken = localStorage.getItem('token') || JSON.stringify({ [sharedToken]: '' })
let accessTokenJson = { [sharedToken]: '' }
try {
accessTokenJson = JSON.parse(accessToken)
}
catch (e) {
}
localStorage.removeItem(CONVERSATION_ID_INFO)
accessTokenJson[sharedToken] = token
localStorage.setItem('token', JSON.stringify(accessTokenJson))
}
export const removeAccessToken = () => {
const sharedToken = globalThis.location.pathname.split('/').slice(-1)[0]
const accessToken = localStorage.getItem('token') || JSON.stringify({ [sharedToken]: '' })
let accessTokenJson = { [sharedToken]: '' }
try {
accessTokenJson = JSON.parse(accessToken)
}
catch (e) {
}
localStorage.removeItem(CONVERSATION_ID_INFO)
delete accessTokenJson[sharedToken]
localStorage.setItem('token', JSON.stringify(accessTokenJson))
}

@ -0,0 +1,96 @@
import React, { useEffect, useState } from 'react'
import cn from '@/utils/classnames'
import { RiCloseLine } from '@remixicon/react'
import { fetchApoTools } from '@/service/tools'
import Conversation from './conversation'
import { initApoToolsEntry } from './constant'
import type { ApoToolTypeInfo } from './types'
import { ApoDisplayDataType } from './types'
import Input from '../../base/input'
import type { BlockEnum } from '../types'
import type { ToolDefaultValue } from '../block-selector/types'
import { Portal } from '@headlessui/react'
type AINodeRecommendProps = {
onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void
apoToolType: ApoToolTypeInfo | null,
showRecommendModal: boolean
closeModal: () => void
}
const AINodeRecommend = ({
onSelect,
apoToolType,
showRecommendModal,
closeModal,
}: AINodeRecommendProps) => {
const [conversation, setConversation] = useState<any[]>([])
const [queryText, setQueryText] = useState('')
const [loading, setLoading] = useState<boolean>(false)
const getAllTools = async () => {
const apoTools = await fetchApoTools(apoToolType, queryText)
setConversation(prev => [
...prev,
{
role: 'ai',
text: '以下是推荐的工具',
data: apoTools[0],
type: ApoDisplayDataType.tool,
},
])
setQueryText('')
setLoading(false)
}
useEffect(() => {
if(apoToolType && showRecommendModal) {
setConversation([
{
role: 'human',
text: `请给出${initApoToolsEntry[apoToolType].label}推荐工具`,
},
])
getAllTools()
}
}, [apoToolType, showRecommendModal])
const handleKeyDown = async (event) => {
if (event.key === 'Enter' && !loading) {
setLoading(true)
setConversation(prev => [
...prev,
{
role: 'human',
text: queryText,
},
])
getAllTools()
}
}
return showRecommendModal
&& <Portal><div className={cn('flex flex-col z-[10000] fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-components-panel-bg shadow-lg border-[0.5px] border-components-panel-border rounded-2xl overflow-y-auto w-1/2 h-3/4')}>
{/* header */}
<div className='flex items-center justify-between p-4 pb-1 text-base font-semibold text-gray-900'>
<div className='p-1 cursor-pointer' onClick={() => closeModal()}>
<RiCloseLine className='w-4 h-4 text-gray-500' />
</div>
</div>
<div className='flex-1 h-0 flex flex-col overflow-hidden'>
<div className='flex-1 overflow-auto'>
<Conversation conversation={conversation} onSelect={onSelect} closeModal={closeModal} />
</div>
<div className='grow-0 shrink-0 flex items-center justify-center p-4'>
<Input
showClearIcon
wrapperClassName='w-full'
value={queryText}
onChange={e => setQueryText(e.target.value)}
onClear={() => setQueryText('')}
onKeyDown={handleKeyDown}
disabled={loading}
/>
</div>
</div>
</div></Portal>
}
export default React.memo(AINodeRecommend)

@ -0,0 +1,16 @@
import type { ApoToolTypeInfo } from './types'
export const initApoToolsEntry: Record<ApoToolTypeInfo, any> = {
select: {
label: 'APO异常检测',
description: 'APO异常检测分析描述',
icon: '',
type: 'select',
},
analysis: {
label: 'APO查询检测',
description: 'APO查询检测',
icon: '',
type: 'analysis',
},
}

@ -0,0 +1,24 @@
import {
createContext,
useRef,
} from 'react'
import { createApoStore } from './store'
type ApoStore = ReturnType<typeof createApoStore>
export const ApoContext = createContext<ApoStore | null>(null)
type ApoProviderProps = {
children: React.ReactNode
}
export const ApoContextProvider = ({ children }: ApoProviderProps) => {
const storeRef = useRef<ApoStore>()
if (!storeRef.current)
storeRef.current = createApoStore()
return (
<ApoContext.Provider value={storeRef.current}>
{children}
</ApoContext.Provider>
)
}

@ -0,0 +1,44 @@
import React from 'react'
import cn from '@/utils/classnames'
import { Markdown } from '@/app/components/base/markdown'
import { ApoDisplayDataType } from './types'
import RecommendTools from './recommend-tools'
import type { ToolDefaultValue } from '../block-selector/types'
import type { BlockEnum } from '../types'
type ConversationProps = {
conversation: any[]
onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void
closeModal: () => void
}
const Conversation = ({ conversation, onSelect, closeModal }: ConversationProps) => {
return <div className='p-2'>{
conversation?.map((con, index) => (
<div key={index} className={cn('relative flex ', con.role === 'human' ? 'justify-end' : '')}>
<div className={cn('rounded p-2 max-w-[80%]')} style={{ backgroundColor: 'rgba(0, 0, 0, 0.04)', color: 'rgba(0, 0, 0, 0.85)' }}>
<Markdown content={con.text} />
{/* data */}
<div>
{
con.type === ApoDisplayDataType.tool && con.data && <>
<RecommendTools provider={con.data} onSelect={onSelect} closeModal={closeModal}/>
</>
}
</div>
</div>
{/* data */}
{/* <div>
{
con.type === ApoDisplayDataType.tool && <>
</>
}
</div> */}
</div>
))
}</div>
}
export default React.memo(Conversation)

@ -0,0 +1,68 @@
import React, { useCallback } from 'react'
import { useLanguage } from '../../header/account-setting/model-provider-page/hooks'
import Tooltip from '../../base/tooltip'
import type { OnSelectBlock } from '../types'
import { BlockEnum } from '../types'
import type { ToolDefaultValue } from '../block-selector/types'
type RecommendToolsPrps = {
provider: any[]
onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void
closeModal: () => void
}
const RecommendTools = ({ provider, onSelect, closeModal }: RecommendToolsPrps) => {
const language = useLanguage()
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
onSelect(type, toolDefaultValue)
closeModal()
}, [])
return <div>
{provider.tools.map(tool => (
<Tooltip
key={tool.name}
position='right'
popupClassName='!p-0 !px-3 !py-2.5 !w-[200px] !leading-[18px] !text-xs !text-gray-700 !border-[0.5px] !border-black/5 !rounded-xl !shadow-lg'
popupContent={(
<div>
{/* <BlockIcon
size='md'
className='mb-2'
type={BlockEnum.Tool}
toolIcon={provider.icon}
/> */}
<div className='mb-1 text-sm leading-5 text-gray-900'>{tool.label[language]}</div>
<div className='text-xs text-gray-700 leading-[18px]'>{tool.description[language]}</div>
</div>
)}
>
<div
key={tool.name}
className='rounded-lg pl-[21px] hover:bg-state-base-hover cursor-pointer'
onClick={(e) => {
const params: Record<string, string> = {}
if (tool.parameters) {
tool.parameters.forEach((item) => {
params[item.name] = ''
})
}
e.stopPropagation()
handleSelect(BlockEnum.Tool, {
provider_id: provider.id,
provider_type: provider.type,
provider_name: provider.name,
tool_name: tool.name,
tool_label: tool.label[language],
title: tool.label[language],
is_team_authorization: provider.is_team_authorization,
output_schema: tool.output_schema,
paramSchemas: tool.parameters,
params,
})
}}
>
<div className='h-8 leading-8 border-l-2 border-divider-subtle pl-4 truncate text-text-secondary system-sm-medium'>{tool.label[language]}</div>
</div>
</Tooltip >
))}</div>
}
export default React.memo(RecommendTools)

@ -0,0 +1,28 @@
const OperatorSider = () => {
const { t } = useTranslation()
const store = useStoreApi()
const workflowStore = useWorkflowStore()
const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, false)
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
const {
getNodes,
} = store.getState()
const nodes = getNodes()
const nodesWithSameType = nodes.filter(node => node.data.type === type)
const { newNode } = generateNewNode({
data: {
...NODES_INITIAL_DATA[type],
title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${type}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${type}`),
...(toolDefaultValue || {}),
_isCandidate: true,
},
position: {
x: 0,
y: 0,
},
})
workflowStore.setState({
candidateNode: newNode,
})
}, [store, workflowStore, t])

@ -0,0 +1,36 @@
import { useContext } from 'react'
import {
useStore as useZustandStore,
} from 'zustand'
import { createStore } from 'zustand/vanilla'
import { ApoContext } from './context'
type Shape = {
showRecommendModal: boolean
setShowRecommendModal: (showRecommendModal: boolean,) => void
apoToolType: string
setApoToolType: (apoToolType: any[],) => void
}
export const createApoStore = () => {
return createStore<Shape>(set => ({
showRecommendModal: false,
setShowRecommendModal: (show) => {
set({ showRecommendModal: show })
},
apoToolType: '',
setApoToolType: apoToolType => set(() => ({ apoToolType })),
}))
}
export function useStore<T>(selector: (state: Shape) => T): T {
const store = useContext(ApoContext)
if (!store)
throw new Error('Missing ApoContext.Provider in the tree')
return useZustandStore(store, selector)
}
export const useApoStore = () => {
return useContext(ApoContext)!
}

@ -0,0 +1,8 @@
export type ApoToolTypeInfo = 'select' | 'analysis' | 'rule'
export enum ApoDisplayDataType {
metric = 'metric',
toplogy = 'toplogy',
alert = 'alert',
log = 'log',
tool = 'tool',
}

@ -0,0 +1,79 @@
import Tooltip from '@/app/components/base/tooltip'
import BlockIcon from '../block-icon'
import { BlockEnum } from '../types'
import cn from '@/utils/classnames'
import { initApoToolsEntry } from '../apo/constant'
import AINodeRecommend from '@/app/components/workflow/apo/ai-node-recommend'
import { useState } from 'react'
import type { ToolDefaultValue } from './types'
type APOToolsProps = {
searchText: string;
onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void
}
const APOTools = ({ searchText, onSelect }: APOToolsProps) => {
const [apoToolType, setApoToolType] = useState()
const [showRecommendModal, setShowRecommendModal] = useState()
const handleSelect = (type: BlockEnum) => {
onSelect(type)
setShowRecommendModal(false)
}
return (
<>
<div className="mb-1 last-of-type:mb-0">
{Object.entries(initApoToolsEntry).map(([key, tool]) => (
<Tooltip
key={key}
position="right"
popupClassName="w-[200px]"
popupContent={
<div>
<BlockIcon
size="md"
className="mb-2"
type={BlockEnum.Tool}
toolIcon={tool.icon}
/>
<div className={cn('grow text-sm text-gray-900 truncate')}>
{tool.label}
</div>
<div className="text-xs text-gray-700 leading-[18px]">
{tool.description}
</div>
{/* <div className={cn('grow text-sm text-gray-900 truncate')}>{tool.label[language]}</div>
<div className='text-xs text-gray-700 leading-[18px]'>{tool.description[language]}</div> */}
</div>
}
>
<div
key={tool.type}
className="flex items-center px-3 w-full h-8 rounded-lg hover:bg-state-base-hover cursor-pointer"
onClick={() => {
setApoToolType(tool.type)
setShowRecommendModal(true)
}}
>
<BlockIcon
className="mr-2 shrink-0"
type={BlockEnum.Tool}
toolIcon={tool.icon}
/>
<div className={cn('grow text-sm text-gray-900 truncate')}>
{tool.label}
</div>
</div>
</Tooltip>
))}
</div>
<AINodeRecommend
onSelect={handleSelect}
apoToolType={apoToolType}
showRecommendModal={showRecommendModal}
closeModal={() => {
setApoToolType(null)
setShowRecommendModal(false)
}}
/>
</>
)
}
export default APOTools

@ -27,7 +27,6 @@ import SearchBox from '@/app/components/plugins/marketplace/search-box'
import {
Plus02,
} from '@/app/components/base/icons/src/vender/line/general'
import classNames from '@/utils/classnames'
type NodeSelectorProps = {
open?: boolean
@ -44,6 +43,7 @@ type NodeSelectorProps = {
availableBlocksTypes?: BlockEnum[]
disabled?: boolean
noBlocks?: boolean
absolute?: boolean
}
const NodeSelector: FC<NodeSelectorProps> = ({
open: openFromProps,
@ -60,6 +60,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
availableBlocksTypes,
disabled,
noBlocks = false,
absolute = true,
}) => {
const { t } = useTranslation()
const [searchText, setSearchText] = useState('')
@ -98,38 +99,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
return t('workflow.tabs.searchTool')
return ''
}, [activeTab, t])
return (
<PortalToFollowElem
placement={placement}
offset={offset}
open={open}
onOpenChange={handleOpenChange}
>
<PortalToFollowElemTrigger
asChild={asChild}
onClick={handleTrigger}
className={triggerInnerClassName}
>
{
trigger
? trigger(open)
: (
<div
className={`
flex items-center justify-center
w-4 h-4 rounded-full bg-components-button-primary-bg text-text-primary-on-surface hover:bg-components-button-primary-bg-hover cursor-pointer z-10
${triggerClassName?.(open)}
`}
style={triggerStyle}
>
<Plus02 className='w-2.5 h-2.5' />
</div>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className={`rounded-lg border-[0.5px] border-gray-200 bg-white shadow-lg ${popupClassName}`}>
const TabContent = () => <div className={`rounded-lg border-[0.5px] border-gray-200 bg-white shadow-lg ${popupClassName}`}>
<div className='px-2 pt-2' onClick={e => e.stopPropagation()}>
{activeTab === TabsEnum.Blocks && (
<Input
@ -164,9 +134,40 @@ const NodeSelector: FC<NodeSelectorProps> = ({
noBlocks={noBlocks}
/>
</div>
return absolute
? <PortalToFollowElem
placement={placement}
offset={offset}
open={open}
onOpenChange={handleOpenChange}
>
<PortalToFollowElemTrigger
asChild={asChild}
onClick={handleTrigger}
className={triggerInnerClassName}
>
{
trigger
? trigger(open)
: (
<div
className={`
flex items-center justify-center
w-4 h-4 rounded-full bg-components-button-primary-bg text-text-primary-on-surface hover:bg-components-button-primary-bg-hover cursor-pointer z-10
${triggerClassName?.(open)}
`}
style={triggerStyle}
>
<Plus02 className='w-2.5 h-2.5' />
</div>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<TabContent/>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
: <TabContent/>
}
export default memo(NodeSelector)

@ -8,6 +8,7 @@ import { TabsEnum } from './types'
import Blocks from './blocks'
import AllTools from './all-tools'
import cn from '@/utils/classnames'
import APOTools from './apoTools'
export type TabsProps = {
activeTab: TabsEnum
@ -58,11 +59,12 @@ const Tabs: FC<TabsProps> = ({
}
{
activeTab === TabsEnum.Blocks && !noBlocks && (
<><APOTools onSelect={onSelect} searchText={searchText}/>
<Blocks
searchText={searchText}
onSelect={onSelect}
availableBlocksTypes={availableBlocksTypes}
/>
/></>
)
}
{

@ -97,6 +97,7 @@ import { useEventEmitterContextContext } from '@/context/event-emitter'
import Confirm from '@/app/components/base/confirm'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { fetchFileUploadConfig } from '@/service/common'
import OperatorSider from '@/app/components/workflow/operator/sider'
const nodeTypes = {
[CUSTOM_NODE]: CustomNode,
@ -444,11 +445,15 @@ const WorkflowWrap = memo(() => {
nodes={nodesData}
edges={edgesData} >
<FeaturesProvider features={initialFeatures}>
<div className='flex h-full'>
<OperatorSider />
<Workflow
nodes={nodesData}
edges={edgesData}
viewport={data?.graph.viewport}
/>
</div>
</FeaturesProvider>
</WorkflowHistoryProvider>
</ReactFlowProvider>

@ -19,7 +19,6 @@ import {
} from '../types'
import { useStore } from '../store'
import Divider from '../../base/divider'
import AddBlock from './add-block'
import TipPopup from './tip-popup'
import { useOperator } from './hooks'
import cn from '@/utils/classnames'
@ -45,7 +44,7 @@ const Control = () => {
return (
<div className='flex items-center p-0.5 rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg shadow-lg text-text-tertiary'>
<AddBlock />
{/* <AddBlock /> */}
<TipPopup title={t('workflow.nodes.note.addNote')}>
<div
className={cn(

@ -0,0 +1,54 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useStoreApi } from 'reactflow'
import { BlockEnum, type OnSelectBlock } from '../types'
import BlockSelector from '../block-selector'
import { NODES_INITIAL_DATA } from '../constants'
import { useAvailableBlocks } from '../hooks'
import { useWorkflowStore } from '../store'
import { generateNewNode } from '../utils'
const OperatorSider = () => {
const { t } = useTranslation()
const store = useStoreApi()
const workflowStore = useWorkflowStore()
const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, false)
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
const {
getNodes,
} = store.getState()
const nodes = getNodes()
const nodesWithSameType = nodes.filter(node => node.data.type === type)
const { newNode } = generateNewNode({
data: {
...NODES_INITIAL_DATA[type],
title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${type}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${type}`),
...(toolDefaultValue || {}),
_isCandidate: true,
},
position: {
x: 0,
y: 0,
},
})
console.log(newNode)
workflowStore.setState({
candidateNode: newNode,
})
}, [store, workflowStore, t])
return <div className="shrink-0 flex flex-col w-[326px] border-r border-divider-burn transition-all h-full relative">
{/* <div className="shrink-0 flex flex-col w-[1px] border-r border-divider-burn transition-all h-full relative"> */}
<BlockSelector
open={true}
onSelect={handleSelect}
placement="left-end"
popupClassName='!min-w-[326px] h-full'
availableBlocksTypes={availableNextBlocks}
absolute={false}
/>
{/* </div> */}
</div>
}
export default OperatorSider

@ -0,0 +1,63 @@
import { memo } from 'react'
import LineChart from '../../base/line-chart'
import { useTranslation } from 'react-i18next'
import Topology from '../../base/topology'
import { usePrepareTopologyData } from '../../base/topology/hook'
import AlertList from '../../base/alert'
import LogContent from '../../base/log/LogContent'
type DataDisplayProps = {
data: string;
}
const TopologyCom = ({ data }) => {
const topologyData = usePrepareTopologyData(data)
return <div className=' w-full h-[400px] relative'><Topology data={topologyData} /></div>
}
const DataDisplay = ({ data }: DataDisplayProps) => {
// const [chartData,setChartData] = useState<any>(null)
const { t } = useTranslation()
let chartData: any = null
try {
chartData = JSON.parse(data)
if(chartData.data?.message) chartData = { type: 'error', data: chartData.data?.message }
}
catch (error) {
console.error('JSON 解析失败:', error)
chartData = {} // 返回一个空对象或其他默认值
}
console.log(chartData)
return (
<div className="relative w-full">
{
chartData?.type
&& <>
<div className="h-6 py-1 text-text-tertiary system-xs-medium-uppercase">
{t('apo.chart.chartTitle')}
</div>
<div className='px-1'>
{['cpu', 'network', 'memory'].includes(chartData?.type) && (
<LineChart type={chartData?.type} data={chartData?.data} />
)}
{chartData?.type === 'topology' && (
<TopologyCom data={chartData?.data} />
)}
{chartData?.type === 'alert' && (
<AlertList {...chartData?.data} />
)}
{chartData?.type === 'log' && (
<LogContent {...chartData?.data} />
)}
{
chartData?.type === 'error' && <span>{chartData?.data}</span>
}
{/* {
!chartData?.type && <></>
} */}
</div></>
}
</div>
)
}
export default memo(DataDisplay)

@ -25,6 +25,7 @@ import type {
} 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 DataDisplay from './data-display'
type Props = {
className?: string
@ -215,6 +216,9 @@ const NodePanel: FC<Props> = ({
/>
</div>
)}
{
nodeInfo.outputs?.text && <DataDisplay data={nodeInfo.outputs.text} />
}
</div>
)}
</div>

@ -15,12 +15,13 @@ import { hasRetryNode } from '@/app/components/workflow/utils'
import { IterationLogTrigger } from '@/app/components/workflow/run/iteration-log'
import { RetryLogTrigger } from '@/app/components/workflow/run/retry-log'
import { AgentLogTrigger } from '@/app/components/workflow/run/agent-log'
import DataDisplay from './data-display'
type ResultPanelProps = {
nodeInfo?: NodeTracing
inputs?: string
process_data?: string
outputs?: string
outputs?: any
status: string
error?: string
elapsed_time?: number
@ -130,6 +131,13 @@ const ResultPanel: FC<ResultPanelProps> = ({
<div className='px-4 py-2'>
<div className='h-[0.5px] divider-subtle' />
</div>
<div className='px-4 py-2'>
{outputs && outputs?.text && <DataDisplay data={outputs.text} />}
</div>
<div className='px-4 py-2'>
<div className='h-[0.5px] divider-subtle' />
</div>
<div className='px-4 py-2'>
<MetaData
status={status}

@ -14,6 +14,7 @@ import {
import { useTranslation } from 'react-i18next'
import { useLogs } from './hooks'
import NodePanel from './node'
import NodePanelForRun from '@/app/components/run/node/node'
import SpecialResultPanel from './special-result-panel'
import type { NodeTracing } from '@/types/workflow'
import formatNodeList from '@/app/components/workflow/run/utils/format-log'
@ -23,6 +24,7 @@ type TracingPanelProps = {
className?: string
hideNodeInfo?: boolean
hideNodeProcessDetail?: boolean
ifForRun?: boolean
}
const TracingPanel: FC<TracingPanelProps> = ({
@ -30,6 +32,7 @@ const TracingPanel: FC<TracingPanelProps> = ({
className,
hideNodeInfo = false,
hideNodeProcessDetail = false,
ifForRun = false,
}) => {
const { t } = useTranslation()
const treeNodes = formatNodeList(list, t)
@ -136,7 +139,15 @@ const TracingPanel: FC<TracingPanelProps> = ({
<div className={cn('pl-4 -mb-1.5 system-2xs-medium-uppercase', isHovered ? 'text-text-tertiary' : 'text-text-quaternary')}>
{node?.parallelDetail?.branchTitle}
</div>
<NodePanel
{
ifForRun ? <NodePanelForRun
nodeInfo={node!}
onShowIterationDetail={handleShowIterationResultList}
onShowRetryDetail={handleShowRetryResultList}
onShowAgentOrToolLog={handleShowAgentOrToolLog}
hideInfo={hideNodeInfo}
hideProcessDetail={hideNodeProcessDetail}
/> : <NodePanel
nodeInfo={node!}
onShowIterationDetail={handleShowIterationResultList}
onShowRetryDetail={handleShowRetryResultList}
@ -144,6 +155,8 @@ const TracingPanel: FC<TracingPanelProps> = ({
hideInfo={hideNodeInfo}
hideProcessDetail={hideNodeProcessDetail}
/>
}
</div>
)
}

@ -0,0 +1,51 @@
import { memo, useCallback } from 'react'
import BlockSelector from './block-selector'
import type { OnSelectBlock } from './types'
import { BlockEnum } from './types'
import { useTranslation } from 'react-i18next'
import { useStoreApi } from 'reactflow'
import { useWorkflowStore } from './store'
import { useAvailableBlocks } from './hooks'
import { generateNewNode } from './utils'
import { NODES_INITIAL_DATA } from './constants'
const ToolSider = () => {
const { t } = useTranslation()
const store = useStoreApi()
const workflowStore = useWorkflowStore()
const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, false)
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
const {
getNodes,
} = store.getState()
const nodes = getNodes()
const nodesWithSameType = nodes.filter(node => node.data.type === type)
const { newNode } = generateNewNode({
data: {
...NODES_INITIAL_DATA[type],
title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${type}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${type}`),
...(toolDefaultValue || {}),
_isCandidate: true,
},
position: {
x: 0,
y: 0,
},
})
workflowStore.setState({
candidateNode: newNode,
})
}, [store, workflowStore, t])
return <div className='w-[328px] h-full'>
<BlockSelector
open={true}
onSelect={handleSelect}
placement='top'
offset={0}
absolute={false}
popupClassName='!w-[328px]'
availableBlocksTypes={availableNextBlocks}
/>
</div>
}
export default memo(ToolSider)

@ -41,6 +41,8 @@ const LocaleLayout = ({
data-pubic-api-prefix={process.env.NEXT_PUBLIC_PUBLIC_API_PREFIX}
data-marketplace-api-prefix={process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX}
data-marketplace-url-prefix={process.env.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX}
data-marketplace-api-prefix={process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX}
data-marketplace-url-prefix={process.env.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX}
data-public-edition={process.env.NEXT_PUBLIC_EDITION}
data-public-support-mail-login={process.env.NEXT_PUBLIC_SUPPORT_MAIL_LOGIN}
data-public-sentry-dsn={process.env.NEXT_PUBLIC_SENTRY_DSN}
@ -49,6 +51,7 @@ const LocaleLayout = ({
data-public-text-generation-timeout-ms={process.env.NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS}
data-public-top-k-max-value={process.env.NEXT_PUBLIC_TOP_K_MAX_VALUE}
data-public-indexing-max-segmentation-tokens-length={process.env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH}
data-v1-api-prefix={process.env.NEXT_PUBLIC_V1_API_PREFIX}
>
<BrowserInitor>
<SentryInitor>

@ -6,6 +6,7 @@ export let apiPrefix = ''
export let publicApiPrefix = ''
export let marketplaceApiPrefix = ''
export let marketplaceUrlPrefix = ''
export let v1ApiPrefix = ''
// NEXT_PUBLIC_API_PREFIX=/console/api NEXT_PUBLIC_PUBLIC_API_PREFIX=/api npm run start
if (process.env.NEXT_PUBLIC_API_PREFIX && process.env.NEXT_PUBLIC_PUBLIC_API_PREFIX) {
@ -38,10 +39,20 @@ else {
marketplaceUrlPrefix = globalThis.document?.body?.getAttribute('data-marketplace-url-prefix') || ''
}
if(process.env.NEXT_PUBLIC_V1_API_PREFIX)
v1ApiPrefix = process.env.NEXT_PUBLIC_V1_API_PREFIX
else if(globalThis.document?.body?.getAttribute('data-v1-api-prefix'))
v1ApiPrefix = globalThis.document?.body?.getAttribute('data-v1-api-prefix')
else
v1ApiPrefix = 'http://localhost:5001/v1'
export const API_PREFIX: string = apiPrefix
export const PUBLIC_API_PREFIX: string = publicApiPrefix
export const MARKETPLACE_API_PREFIX: string = marketplaceApiPrefix
export const MARKETPLACE_URL_PREFIX: string = marketplaceUrlPrefix
export const V1_API_PREFIX: string = v1ApiPrefix
const EDITION = process.env.NEXT_PUBLIC_EDITION || globalThis.document?.body?.getAttribute('data-public-edition') || 'SELF_HOSTED'
export const IS_CE_EDITION = EDITION === 'SELF_HOSTED'

@ -0,0 +1,3 @@
const translation = {}
export default translation

@ -0,0 +1,11 @@
const translation = {
toolType: {
select: 'APO Select',
analysis: 'APO Analysis',
},
chart: {
chartTitle: 'Chart Display',
},
}
export default translation

@ -0,0 +1,3 @@
const translation = {}
export default translation

@ -0,0 +1,3 @@
const translation = {}
export default translation

@ -0,0 +1,3 @@
const translation = {}
export default translation

@ -0,0 +1,3 @@
const translation = {}
export default translation

@ -31,6 +31,7 @@ const loadLangResources = (lang: string) => ({
plugin: require(`./${lang}/plugin`).default,
pluginTags: require(`./${lang}/plugin-tags`).default,
time: require(`./${lang}/time`).default,
apo: require(`./${lang}/apo`).default,
},
})

@ -0,0 +1,3 @@
const translation = {}
export default translation

@ -0,0 +1,3 @@
const translation = {}
export default translation

@ -0,0 +1,3 @@
const translation = {}
export default translation

@ -0,0 +1,3 @@
const translation = {}
export default translation

@ -0,0 +1,3 @@
const translation = {}
export default translation

@ -0,0 +1,3 @@
const translation = {}
export default translation

@ -0,0 +1,3 @@
const translation = {}
export default translation

@ -0,0 +1,3 @@
const translation = {}
export default translation

@ -0,0 +1,3 @@
const translation = {}
export default translation

@ -0,0 +1,3 @@
const translation = {}
export default translation

@ -0,0 +1,3 @@
const translation = {}
export default translation

@ -0,0 +1,3 @@
const translation = {}
export default translation

@ -0,0 +1,11 @@
const translation = {
toolType: {
select: 'APO查询数据',
analysis: 'APO异常检测',
},
chart: {
chartTitle: '图表渲染',
},
}
export default translation

@ -0,0 +1,3 @@
const translation = {}
export default translation

@ -1,4 +1,4 @@
import { API_PREFIX, IS_CE_EDITION, PUBLIC_API_PREFIX } from '@/config'
import { API_PREFIX, IS_CE_EDITION, PUBLIC_API_PREFIX, V1_API_PREFIX } from '@/config'
import { refreshAccessTokenOrRelogin } from './refresh-token'
import Toast from '@/app/components/base/toast'
import type { AnnotationReply, MessageEnd, MessageReplace, ThoughtItem } from '@/app/components/base/chat/chat/type'
@ -362,7 +362,6 @@ export const ssePost = (
const accessToken = getAccessToken(isPublicAPI)
options.headers!.set('Authorization', `Bearer ${accessToken}`)
globalThis.fetch(urlWithPrefix, options as RequestInit)
.then((res) => {
if (!/^(2|3)\d{2}$/.test(String(res.status))) {
@ -534,3 +533,111 @@ export const patch = <T>(url: string, options = {}, otherOptions?: IOtherOptions
export const patchPublic = <T>(url: string, options = {}, otherOptions?: IOtherOptions) => {
return patch<T>(url, options, { ...otherOptions, isPublicAPI: true })
}
// copy from ssePost
export const sseV1Post = (
url: string,
fetchOptions: FetchOptionType,
otherOptions: IOtherOptions,
apiKey: string,
) => {
const {
isPublicAPI = false,
onData,
onCompleted,
onThought,
onFile,
onMessageEnd,
onMessageReplace,
onWorkflowStarted,
onWorkflowFinished,
onNodeStarted,
onNodeFinished,
onIterationStart,
onIterationNext,
onIterationFinish,
onNodeRetry,
onParallelBranchStarted,
onParallelBranchFinished,
onTextChunk,
onTTSChunk,
onTTSEnd,
onTextReplace,
onAgentLog,
onError,
getAbortController,
} = otherOptions
const abortController = new AbortController()
const token = localStorage.getItem('console_token')
console.log(baseOptions)
const options = Object.assign({}, baseOptions, {
method: 'POST',
signal: abortController.signal,
headers: new Headers({
Authorization: `Bearer ${token}`,
}),
} as RequestInit, fetchOptions)
const contentType = (options.headers as Headers).get('Content-Type')
if (!contentType)
(options.headers as Headers).set('Content-Type', ContentType.json)
getAbortController?.(abortController)
const urlPrefix = V1_API_PREFIX
const urlWithPrefix = (url.startsWith('http://') || url.startsWith('https://'))
? url
: `${urlPrefix}${url.startsWith('/') ? url : `/${url}`}`
const { body } = options
if (body)
options.body = JSON.stringify(body)
const accessToken = getAccessToken(isPublicAPI)
options.headers!.set('Authorization', `Bearer ${apiKey}`)
console.log(options)
globalThis.fetch(urlWithPrefix, options as RequestInit)
.then((res) => {
if (!/^(2|3)\d{2}$/.test(String(res.status))) {
if (res.status === 401) {
refreshAccessTokenOrRelogin(TIME_OUT).then(() => {
ssePost(url, fetchOptions, otherOptions)
}).catch(() => {
res.json().then((data: any) => {
if (isPublicAPI) {
if (data.code === 'web_sso_auth_required')
requiredWebSSOLogin()
if (data.code === 'unauthorized') {
removeAccessToken()
globalThis.location.reload()
}
}
})
})
}
else {
res.json().then((data) => {
Toast.notify({ type: 'error', message: data.message || 'Server Error' })
})
onError?.('Server Error')
}
return
}
return handleStream(res, (str: string, isFirstMessage: boolean, moreInfo: IOnDataMoreInfo) => {
if (moreInfo.errorMessage) {
onError?.(moreInfo.errorMessage, moreInfo.errorCode)
// TypeError: Cannot assign to read only property ... will happen in page leave, so it should be ignored.
if (moreInfo.errorMessage !== 'AbortError: The user aborted a request.' && !moreInfo.errorMessage.includes('TypeError: Cannot assign to read only property'))
Toast.notify({ type: 'error', message: moreInfo.errorMessage })
return
}
onData?.(str, isFirstMessage, moreInfo)
}, onCompleted, onThought, onMessageEnd, onMessageReplace, onFile, onWorkflowStarted, onWorkflowFinished, onNodeStarted, onNodeFinished, onIterationStart, onIterationNext, onIterationFinish, onNodeRetry, onParallelBranchStarted, onParallelBranchFinished, onTextChunk, onTTSChunk, onTTSEnd, onTextReplace, onAgentLog)
}).catch((e) => {
if (e.toString() !== 'AbortError: The user aborted a request.' && !e.toString().errorMessage.includes('TypeError: Cannot assign to read only property'))
Toast.notify({ type: 'error', message: e })
onError?.(e)
})
}

@ -2,6 +2,7 @@ import type { IOnCompleted, IOnData, IOnError, IOnFile, IOnIterationFinished, IO
import {
del as consoleDel, get as consoleGet, patch as consolePatch, post as consolePost,
delPublic as del, getPublic as get, patchPublic as patch, postPublic as post, ssePost,
sseV1Post,
} from './base'
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
import type {
@ -225,3 +226,37 @@ export const fetchAccessToken = async (appCode: string) => {
headers.append('X-App-Code', appCode)
return get('/passport', { headers }) as Promise<{ access_token: string }>
}
export const sendWorkflowRun = async (
body: Record<string, any>,
{
onWorkflowStarted,
onNodeStarted,
onNodeFinished,
onWorkflowFinished,
onIterationStart,
onIterationNext,
onIterationFinish,
onTextChunk,
onTextReplace,
}: {
onWorkflowStarted: IOnWorkflowStarted
onNodeStarted: IOnNodeStarted
onNodeFinished: IOnNodeFinished
onWorkflowFinished: IOnWorkflowFinished
onIterationStart: IOnIterationStarted
onIterationNext: IOnIterationNext
onIterationFinish: IOnIterationFinished
onTextChunk: IOnTextChunk
onTextReplace: IOnTextReplace
},
isInstalledApp: boolean,
apiKey: string,
) => {
return sseV1Post('workflows/run', {
body: {
...body,
response_mode: 'streaming',
},
}, { onNodeStarted, onWorkflowStarted, onWorkflowFinished, isPublicAPI: !isInstalledApp, onNodeFinished, onIterationStart, onIterationNext, onIterationFinish, onTextChunk, onTextReplace }, apiKey)
}

@ -158,3 +158,12 @@ export const deleteWorkflowTool = (toolID: string) => {
},
})
}
export const fetchApoTools = (apoToolType: string, queryText: string) => {
return get('/workspaces/current/tools/apo', {
params: {
tool_type: apoToolType,
query: queryText,
},
})
}

Loading…
Cancel
Save