add workflow process

This commit is contained in:
JzoNg
2024-04-23 17:57:24 +08:00
parent c73753138d
commit 30509d92a3
53 changed files with 1970 additions and 28 deletions

View File

@ -0,0 +1,119 @@
import type { FC } from 'react'
import { memo } from 'react'
import { BlockEnum } from '@/types/app'
import {
Answer,
Code,
End,
Home,
Http,
IfElse,
KnowledgeRetrieval,
Llm,
QuestionClassifier,
TemplatingTransform,
VariableX,
} from '@/app/components/base/icons/workflow'
import AppIcon from '@/app/components/base/app-icon'
type BlockIconProps = {
type: BlockEnum
size?: string
className?: string
toolIcon?: string | { content: string; background: string }
}
const ICON_CONTAINER_CLASSNAME_SIZE_MAP: Record<string, string> = {
xs: 'w-4 h-4 rounded-[5px] shadow-xs',
sm: 'w-5 h-5 rounded-md shadow-xs',
md: 'w-6 h-6 rounded-lg shadow-md',
}
const getIcon = (type: BlockEnum, className: string) => {
return {
[BlockEnum.Start]: <Home className={className} />,
[BlockEnum.LLM]: <Llm className={className} />,
[BlockEnum.Code]: <Code className={className} />,
[BlockEnum.End]: <End className={className} />,
[BlockEnum.IfElse]: <IfElse className={className} />,
[BlockEnum.HttpRequest]: <Http className={className} />,
[BlockEnum.Answer]: <Answer className={className} />,
[BlockEnum.KnowledgeRetrieval]: <KnowledgeRetrieval className={className} />,
[BlockEnum.QuestionClassifier]: <QuestionClassifier className={className} />,
[BlockEnum.TemplateTransform]: <TemplatingTransform className={className} />,
[BlockEnum.VariableAssigner]: <VariableX className={className} />,
[BlockEnum.Tool]: <VariableX className={className} />,
}[type]
}
const ICON_CONTAINER_BG_COLOR_MAP: Record<string, string> = {
[BlockEnum.Start]: 'bg-[#2970FF]',
[BlockEnum.LLM]: 'bg-[#6172F3]',
[BlockEnum.Code]: 'bg-[#2E90FA]',
[BlockEnum.End]: 'bg-[#F79009]',
[BlockEnum.IfElse]: 'bg-[#06AED4]',
[BlockEnum.HttpRequest]: 'bg-[#875BF7]',
[BlockEnum.Answer]: 'bg-[#F79009]',
[BlockEnum.KnowledgeRetrieval]: 'bg-[#16B364]',
[BlockEnum.QuestionClassifier]: 'bg-[#16B364]',
[BlockEnum.TemplateTransform]: 'bg-[#2E90FA]',
[BlockEnum.VariableAssigner]: 'bg-[#2E90FA]',
}
const BlockIcon: FC<BlockIconProps> = ({
type,
size = 'sm',
className,
toolIcon,
}) => {
return (
<div className={`
flex items-center justify-center border-[0.5px] border-white/[0.02] text-white
${ICON_CONTAINER_CLASSNAME_SIZE_MAP[size]}
${ICON_CONTAINER_BG_COLOR_MAP[type]}
${toolIcon && '!shadow-none'}
${className}
`}
>
{
type !== BlockEnum.Tool && (
getIcon(type, size === 'xs' ? 'w-3 h-3' : 'w-3.5 h-3.5')
)
}
{
type === BlockEnum.Tool && toolIcon && (
<>
{
typeof toolIcon === 'string'
? (
<div
className='shrink-0 w-full h-full bg-cover bg-center rounded-md'
style={{
backgroundImage: `url(${toolIcon})`,
}}
></div>
)
: (
<AppIcon
className='shrink-0 !w-full !h-full'
size='tiny'
icon={toolIcon?.content}
background={toolIcon?.background}
/>
)
}
</>
)
}
</div>
)
}
export const VarBlockIcon: FC<BlockIconProps> = ({
type,
className,
}) => {
return (
<>
{getIcon(type, `w-3 h-3 ${className}`)}
</>
)
}
export default memo(BlockIcon)

View File

@ -0,0 +1,122 @@
'use client'
import type { FC } from 'react'
import Editor, { loader } from '@monaco-editor/react'
import React, { useRef } from 'react'
import Base from '../editor/base'
import { CodeLanguage } from '@/types/app'
import './style.css'
// load file from local instead of cdn https://github.com/suren-atoyan/monaco-react/issues/482
loader.config({ paths: { vs: '/vs' } })
type Props = {
value?: string | object
onChange?: (value: string) => void
title: JSX.Element
language: CodeLanguage
headerRight?: JSX.Element
readOnly?: boolean
isJSONStringifyBeauty?: boolean
height?: number
}
const languageMap = {
[CodeLanguage.javascript]: 'javascript',
[CodeLanguage.python3]: 'python',
[CodeLanguage.json]: 'json',
}
const CodeEditor: FC<Props> = ({
value = '',
onChange = () => { },
title,
headerRight,
language,
readOnly,
isJSONStringifyBeauty,
height,
}) => {
const [isFocus, setIsFocus] = React.useState(false)
const handleEditorChange = (value: string | undefined) => {
onChange(value || '')
}
const editorRef = useRef(null)
const handleEditorDidMount = (editor: any, monaco: any) => {
editorRef.current = editor
editor.onDidFocusEditorText(() => {
setIsFocus(true)
})
editor.onDidBlurEditorText(() => {
setIsFocus(false)
})
monaco.editor.defineTheme('blur-theme', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': '#F2F4F7',
},
})
monaco.editor.defineTheme('focus-theme', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': '#ffffff',
},
})
}
const outPutValue = (() => {
if (!isJSONStringifyBeauty)
return value as string
try {
return JSON.stringify(value as object, null, 2)
}
catch (e) {
return value as string
}
})()
return (
<div>
<Base
title={title}
value={outPutValue}
headerRight={headerRight}
isFocus={isFocus && !readOnly}
minHeight={height || 200}
>
<>
{/* https://www.npmjs.com/package/@monaco-editor/react */}
<Editor
className='h-full'
// language={language === CodeLanguage.javascript ? 'javascript' : 'python'}
language={languageMap[language] || 'javascript'}
theme={isFocus ? 'focus-theme' : 'blur-theme'}
value={outPutValue}
onChange={handleEditorChange}
// https://microsoft.github.io/monaco-editor/typedoc/interfaces/editor.IEditorOptions.html
options={{
readOnly,
domReadOnly: true,
quickSuggestions: false,
minimap: { enabled: false },
lineNumbersMinChars: 1, // would change line num width
wordWrap: 'on', // auto line wrap
// lineNumbers: (num) => {
// return <div>{num}</div>
// }
}}
onMount={handleEditorDidMount}
/>
</>
</Base>
</div>
)
}
export default React.memo(CodeEditor)

View File

@ -0,0 +1,8 @@
.margin-view-overlays {
padding-left: 10px;
}
/* hide readonly tooltip */
.monaco-editor-overlaymessage {
display: none !important;
}

View File

@ -0,0 +1,81 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useRef, useState } from 'react'
import copy from 'copy-to-clipboard'
import cn from 'classnames'
import PromptEditorHeightResizeWrap from './prompt-editor-height-resize-wrap'
import ToggleExpandBtn from './toggle-expand-btn'
import useToggleExpend from './use-toggle-expend'
import { Clipboard, ClipboardCheck } from '@/app/components/base/icons/line/files'
type Props = {
className?: string
title: JSX.Element | string
headerRight?: JSX.Element
children: JSX.Element
minHeight?: number
value: string
isFocus: boolean
}
const Base: FC<Props> = ({
className,
title,
headerRight,
children,
minHeight = 120,
value,
isFocus,
}) => {
const ref = useRef<HTMLDivElement>(null)
const {
wrapClassName,
isExpand,
setIsExpand,
editorExpandHeight,
} = useToggleExpend({ ref, hasFooter: false })
const editorContentMinHeight = minHeight - 28
const [editorContentHeight, setEditorContentHeight] = useState(editorContentMinHeight)
const [isCopied, setIsCopied] = React.useState(false)
const handleCopy = useCallback(() => {
copy(value)
setIsCopied(true)
}, [value])
return (
<div className={cn(wrapClassName)}>
<div ref={ref} className={cn(className, isExpand && 'h-full', 'rounded-lg border', isFocus ? 'bg-white border-gray-200' : 'bg-gray-100 border-gray-100 overflow-hidden')}>
<div className='flex justify-between items-center h-7 pt-1 pl-3 pr-2'>
<div className='text-xs font-semibold text-gray-700'>{title}</div>
<div className='flex items-center'>
{headerRight}
{!isCopied
? (
<Clipboard className='mx-1 w-3.5 h-3.5 text-gray-500 cursor-pointer' onClick={handleCopy} />
)
: (
<ClipboardCheck className='mx-1 w-3.5 h-3.5 text-gray-500' />
)
}
<div className='ml-1'>
<ToggleExpandBtn isExpand={isExpand} onExpandChange={setIsExpand} />
</div>
</div>
</div>
<PromptEditorHeightResizeWrap
height={isExpand ? editorExpandHeight : editorContentHeight}
minHeight={editorContentMinHeight}
onHeightChange={setEditorContentHeight}
hideResize={isExpand}
>
<div className='h-full pb-2'>
{children}
</div>
</PromptEditorHeightResizeWrap>
</div>
</div>
)
}
export default React.memo(Base)

View File

@ -0,0 +1,95 @@
'use client'
import React, { useCallback, useEffect, useState } from 'react'
import type { FC } from 'react'
import { useDebounceFn } from 'ahooks'
import cn from 'classnames'
type Props = {
className?: string
height: number
minHeight: number
onHeightChange: (height: number) => void
children: JSX.Element
footer?: JSX.Element
hideResize?: boolean
}
const PromptEditorHeightResizeWrap: FC<Props> = ({
className,
height,
minHeight,
onHeightChange,
children,
footer,
hideResize,
}) => {
const [clientY, setClientY] = useState(0)
const [isResizing, setIsResizing] = useState(false)
const [prevUserSelectStyle, setPrevUserSelectStyle] = useState(getComputedStyle(document.body).userSelect)
const handleStartResize = useCallback((e: React.MouseEvent<HTMLElement>) => {
setClientY(e.clientY)
setIsResizing(true)
setPrevUserSelectStyle(getComputedStyle(document.body).userSelect)
document.body.style.userSelect = 'none'
}, [])
const handleStopResize = useCallback(() => {
setIsResizing(false)
document.body.style.userSelect = prevUserSelectStyle
}, [prevUserSelectStyle])
const { run: didHandleResize } = useDebounceFn((e) => {
if (!isResizing)
return
const offset = e.clientY - clientY
let newHeight = height + offset
setClientY(e.clientY)
if (newHeight < minHeight)
newHeight = minHeight
onHeightChange(newHeight)
}, {
wait: 0,
})
const handleResize = useCallback(didHandleResize, [isResizing, height, minHeight, clientY])
useEffect(() => {
document.addEventListener('mousemove', handleResize)
return () => {
document.removeEventListener('mousemove', handleResize)
}
}, [handleResize])
useEffect(() => {
document.addEventListener('mouseup', handleStopResize)
return () => {
document.removeEventListener('mouseup', handleStopResize)
}
}, [handleStopResize])
return (
<div
className='relative'
>
<div className={cn(className, 'overflow-y-auto')}
style={{
height,
}}
>
{children}
</div>
{/* resize handler */}
{footer}
{!hideResize && (
<div
className='absolute bottom-0 left-0 w-full flex justify-center h-2 cursor-row-resize'
onMouseDown={handleStartResize}>
<div className='w-5 h-[3px] rounded-sm bg-gray-300'></div>
</div>
)}
</div>
)
}
export default React.memo(PromptEditorHeightResizeWrap)

View File

@ -0,0 +1,25 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import Expand04 from '@/app/components/base/icons/solid/expand-04'
import Collapse04 from '@/app/components/base/icons/line/arrows/collapse-04'
type Props = {
isExpand: boolean
onExpandChange: (isExpand: boolean) => void
}
const ExpandBtn: FC<Props> = ({
isExpand,
onExpandChange,
}) => {
const handleToggle = useCallback(() => {
onExpandChange(!isExpand)
}, [isExpand])
const Icon = isExpand ? Collapse04 : Expand04
return (
<Icon className='w-3.5 h-3.5 text-gray-500 cursor-pointer' onClick={handleToggle} />
)
}
export default React.memo(ExpandBtn)

View File

@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
type Params = {
ref: React.RefObject<HTMLDivElement>
hasFooter?: boolean
}
const useToggleExpend = ({ ref, hasFooter = true }: Params) => {
const [isExpand, setIsExpand] = useState(false)
const [wrapHeight, setWrapHeight] = useState(ref.current?.clientHeight)
const editorExpandHeight = isExpand ? wrapHeight! - (hasFooter ? 56 : 29) : 0
useEffect(() => {
setWrapHeight(ref.current?.clientHeight)
}, [isExpand])
const wrapClassName = isExpand && 'absolute z-10 left-4 right-6 top-[52px] bottom-0 pb-4 bg-white'
return {
wrapClassName,
editorExpandHeight,
isExpand,
setIsExpand,
}
}
export default useToggleExpend

View File

@ -0,0 +1,132 @@
'use client'
import type { FC } from 'react'
import { useEffect, useState } from 'react'
import cn from 'classnames'
import BlockIcon from './block-icon'
import CodeEditor from './code-editor'
import { CodeLanguage } from '@/types/app'
import AlertCircle from '@/app/components/base/icons/line/alert-circle'
import AlertTriangle from '@/app/components/base/icons/line/alert-triangle'
import Loading02 from '@/app/components/base/icons/line/loading-02'
import CheckCircle from '@/app/components/base/icons/line/check-circle'
import ChevronRight from '@/app/components/base/icons/line/chevron-right'
import type { NodeTracing } from '@/types/app'
type Props = {
nodeInfo: NodeTracing
hideInfo?: boolean
}
const NodePanel: FC<Props> = ({ nodeInfo, hideInfo = false }) => {
const [collapseState, setCollapseState] = useState<boolean>(true)
const getTime = (time: number) => {
if (time < 1)
return `${(time * 1000).toFixed(3)} ms`
if (time > 60)
return `${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 `${parseFloat((tokens / 1000).toFixed(3))}K`
if (tokens >= 1000000)
return `${parseFloat((tokens / 1000000).toFixed(3))}M`
}
useEffect(() => {
setCollapseState(!nodeInfo.expand)
}, [nodeInfo.expand])
return (
<div className={cn('px-4 py-1', hideInfo && '!p-0')}>
<div className={cn('group transition-all bg-white border border-gray-100 rounded-2xl shadow-xs hover:shadow-md', hideInfo && '!rounded-lg')}>
<div
className={cn(
'flex items-center pl-[6px] pr-3 cursor-pointer',
hideInfo ? 'py-2' : 'py-3',
!collapseState && (hideInfo ? '!pb-1' : '!pb-2'),
)}
onClick={() => setCollapseState(!collapseState)}
>
<ChevronRight
className={cn(
'shrink-0 w-3 h-3 mr-1 text-gray-400 transition-all group-hover:text-gray-500',
!collapseState && 'rotate-90',
)}
/>
<BlockIcon size={hideInfo ? 'xs' : 'sm'} className={cn('shrink-0 mr-2', hideInfo && '!mr-1')} type={nodeInfo.node_type} toolIcon={nodeInfo.extras?.icon || nodeInfo.extras} />
<div className={cn(
'grow text-gray-700 text-[13px] leading-[16px] font-semibold truncate',
hideInfo && '!text-xs',
)} title={nodeInfo.title}>{nodeInfo.title}</div>
{nodeInfo.status !== 'running' && !hideInfo && (
<div className='shrink-0 text-gray-500 text-xs leading-[18px]'>{`${getTime(nodeInfo.elapsed_time || 0)} · ${getTokenCount(nodeInfo.execution_metadata?.total_tokens || 0)} tokens`}</div>
)}
{nodeInfo.status === 'succeeded' && (
<CheckCircle className='shrink-0 ml-2 w-3.5 h-3.5 text-[#12B76A]' />
)}
{nodeInfo.status === 'failed' && (
<AlertCircle className='shrink-0 ml-2 w-3.5 h-3.5 text-[#F04438]' />
)}
{nodeInfo.status === 'stopped' && (
<AlertTriangle className='shrink-0 ml-2 w-3.5 h-3.5 text-[#F79009]' />
)}
{nodeInfo.status === 'running' && (
<div className='shrink-0 flex items-center text-primary-600 text-[13px] leading-[16px] font-medium'>
<Loading02 className='mr-1 w-3.5 h-3.5 animate-spin' />
<span>Running</span>
</div>
)}
</div>
{!collapseState && (
<div className='pb-2'>
<div className={cn('px-[10px] py-1', hideInfo && '!px-2 !py-0.5')}>
{nodeInfo.status === 'failed' && (
<div className='px-3 py-[10px] bg-[#fef3f2] rounded-lg border-[0.5px] border-[rbga(0,0,0,0.05)] text-xs leading-[18px] text-[#d92d20] shadow-xs'>{nodeInfo.error}</div>
)}
</div>
{nodeInfo.inputs && (
<div className={cn('px-[10px] py-1', hideInfo && '!px-2 !py-0.5')}>
<CodeEditor
readOnly
title={<div>INPUT</div>}
language={CodeLanguage.json}
value={nodeInfo.inputs}
isJSONStringifyBeauty
/>
</div>
)}
{nodeInfo.process_data && (
<div className={cn('px-[10px] py-1', hideInfo && '!px-2 !py-0.5')}>
<CodeEditor
readOnly
title={<div>PROCESS DATA</div>}
language={CodeLanguage.json}
value={nodeInfo.process_data}
isJSONStringifyBeauty
/>
</div>
)}
{nodeInfo.outputs && (
<div className={cn('px-[10px] py-1', hideInfo && '!px-2 !py-0.5')}>
<CodeEditor
readOnly
title={<div>OUTPUT</div>}
language={CodeLanguage.json}
value={nodeInfo.outputs}
isJSONStringifyBeauty
/>
</div>
)}
</div>
)}
</div>
</div>
)
}
export default NodePanel

View File

@ -0,0 +1,104 @@
import {
useEffect,
useMemo,
useState,
} from 'react'
import cn from 'classnames'
import NodePanel from './node'
import type { WorkflowProcess } from '@/types/app'
import CheckCircle from '@/app/components/base/icons/solid/general/check-circle'
import AlertCircle from '@/app/components/base/icons/solid/alert-circle'
import Loading02 from '@/app/components/base/icons/line/loading-02'
import ChevronRight from '@/app/components/base/icons/line/chevron-right'
import { WorkflowRunningStatus } from '@/types/app'
type WorkflowProcessProps = {
data: WorkflowProcess
grayBg?: boolean
expand?: boolean
hideInfo?: boolean
}
const WorkflowProcessItem = ({
data,
grayBg,
expand = false,
hideInfo = false,
}: WorkflowProcessProps) => {
const [collapse, setCollapse] = useState(!expand)
const running = data.status === WorkflowRunningStatus.Running
const succeeded = data.status === WorkflowRunningStatus.Succeeded
const failed = data.status === WorkflowRunningStatus.Failed || data.status === WorkflowRunningStatus.Stopped
const background = useMemo(() => {
if (running && !collapse)
return 'linear-gradient(180deg, #E1E4EA 0%, #EAECF0 100%)'
if (succeeded && !collapse)
return 'linear-gradient(180deg, #ECFDF3 0%, #F6FEF9 100%)'
if (failed && !collapse)
return 'linear-gradient(180deg, #FEE4E2 0%, #FEF3F2 100%)'
}, [running, succeeded, failed, collapse])
useEffect(() => {
setCollapse(!expand)
}, [expand])
return (
<div
className={cn(
'mb-2 rounded-xl border-[0.5px] border-black/[0.08]',
collapse ? 'py-[7px]' : hideInfo ? 'pt-2 pb-1' : 'py-2',
collapse && (!grayBg ? 'bg-white' : 'bg-gray-50'),
hideInfo ? 'mx-[-8px] px-1' : 'w-full px-3',
)}
style={{
background,
}}
>
<div
className={cn(
'flex items-center h-[18px] cursor-pointer',
hideInfo && 'px-[6px]',
)}
onClick={() => setCollapse(!collapse)}
>
{
running && (
<Loading02 className='shrink-0 mr-1 w-3 h-3 text-[#667085] animate-spin' />
)
}
{
succeeded && (
<CheckCircle className='shrink-0 mr-1 w-3 h-3 text-[#12B76A]' />
)
}
{
failed && (
<AlertCircle className='shrink-0 mr-1 w-3 h-3 text-[#F04438]' />
)
}
<div className='grow text-xs font-medium text-gray-700 leading-[18px]'>Workflow Process</div>
<ChevronRight className={`'ml-1 w-3 h-3 text-gray-500' ${collapse ? '' : 'rotate-90'}`} />
</div>
{
!collapse && (
<div className='mt-1.5'>
{
data.tracing.map(node => (
<div key={node.id} className='mb-0.5 last-of-type:mb-0'>
<NodePanel
nodeInfo={node}
hideInfo={hideInfo}
/>
</div>
))
}
</div>
)
}
</div>
)
}
export default WorkflowProcessItem