feat(web): workflow run time ui

This commit is contained in:
zhaoying
2026-04-07 14:08:26 +08:00
parent 30b512e554
commit 675d0fc5ef
3 changed files with 112 additions and 65 deletions

View File

@@ -1,8 +1,8 @@
/* /*
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-02 15:15:11 * @Date: 2026-02-02 15:15:11
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-02 15:15:11 * @Last Modified time: 2026-04-07 14:04:33
*/ */
/** /**
* CodeBlock Component * CodeBlock Component
@@ -27,6 +27,7 @@ type ICodeBlockProps = {
needCopy?: boolean; needCopy?: boolean;
size?: 'small' | 'default'; size?: 'small' | 'default';
showLineNumbers?: boolean; showLineNumbers?: boolean;
background?: string;
} }
/** Code block component for displaying formatted code with optional copy functionality */ /** Code block component for displaying formatted code with optional copy functionality */
@@ -34,7 +35,8 @@ const CodeBlock: FC<ICodeBlockProps> = ({
value, value,
needCopy = true, needCopy = true,
size = 'default', size = 'default',
showLineNumbers = false showLineNumbers = false,
background = '#F0F3F8'
}) => { }) => {
return ( return (
@@ -43,7 +45,7 @@ const CodeBlock: FC<ICodeBlockProps> = ({
style={atelierHeathLight} style={atelierHeathLight}
customStyle={{ customStyle={{
padding: '8px 12px 8px 12px', padding: '8px 12px 8px 12px',
backgroundColor: '#F0F3F8', backgroundColor: background,
borderRadius: 8, borderRadius: 8,
fontSize: size === 'small' ? 12 : 14, fontSize: size === 'small' ? 12 : 14,
wordBreak: 'break-all' wordBreak: 'break-all'

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-24 17:57:08 * @Date: 2026-02-24 17:57:08
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-12 13:39:24 * @Last Modified time: 2026-04-07 14:05:50
*/ */
/* /*
* Runtime Component * Runtime Component
@@ -18,13 +18,15 @@
import { type FC, useState } from 'react' import { type FC, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import clsx from 'clsx' import clsx from 'clsx'
import { Space, Button, Collapse, Flex } from 'antd' import { App, Button, Collapse, Flex } from 'antd'
import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined, RightOutlined, ArrowLeftOutlined } from '@ant-design/icons' import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined, RightOutlined, ArrowLeftOutlined } from '@ant-design/icons'
import copy from 'copy-to-clipboard'
import styles from './chat.module.css' import styles from './chat.module.css'
import type { ChatItem } from '@/components/Chat/types' import type { ChatItem } from '@/components/Chat/types'
import Markdown from '@/components/Markdown' import Markdown from '@/components/Markdown'
import CodeBlock from '@/components/Markdown/CodeBlock' import CodeBlock from '@/components/Markdown/CodeBlock'
import RbAlert from '@/components/RbAlert'
/** /**
* Runtime component props * Runtime component props
@@ -36,10 +38,12 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
index index
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { message } = App.useApp()
// Stores the currently selected detail view (for nested loop/iteration exploration) // Stores the currently selected detail view (for nested loop/iteration exploration)
const [detail, setDetail] = useState<any>(null) const [detail, setDetail] = useState<any>(null)
// Tracks whether the current detail view is for a loop (true) or iteration (false) // Tracks whether the current detail view is for a loop (true) or iteration (false)
const [loop, setLoop] = useState<boolean | null>(null) const [loop, setLoop] = useState<boolean | null>(null)
const [expanded, setExpanded] = useState(false)
/** /**
* Handles navigation into nested loop/iteration details * Handles navigation into nested loop/iteration details
@@ -57,7 +61,7 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
* @returns Tailwind CSS class for appropriate color * @returns Tailwind CSS class for appropriate color
*/ */
const getStatus = (status?: string) => { const getStatus = (status?: string) => {
return status === 'completed' ? 'rb:text-[#369F21]' : status === 'failed' ? 'rb:text-[#FF5D34]' : 'rb:text-[#5B6167]' return status === 'completed' ? 'rb:text-[#369F21]!' : status === 'failed' ? 'rb:text-[#FF5D34]!' : 'rb:text-[#5B6167]!'
} }
/** /**
@@ -76,7 +80,7 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
return ( return (
<Space size={8} direction="vertical" className="rb:w-full!"> <Flex gap={8} vertical>
{Object.entries(groupedByCycle).map(([cycleIdx, items]: [string, any]) => { {Object.entries(groupedByCycle).map(([cycleIdx, items]: [string, any]) => {
return ( return (
<Collapse <Collapse
@@ -92,7 +96,7 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
/> />
) )
})} })}
</Space> </Flex>
) )
} }
@@ -103,7 +107,7 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
*/ */
const renderChild = (list: any) => { const renderChild = (list: any) => {
if (Array.isArray(list)) { if (Array.isArray(list)) {
return <Space size={8} direction="vertical" className="rb:w-full!"> return <Flex gap={8} vertical>
{list?.map(vo => { {list?.map(vo => {
const isLoop = vo.node_type === 'loop'; const isLoop = vo.node_type === 'loop';
// Render cycle variables for loop nodes without node_name // Render cycle variables for loop nodes without node_name
@@ -114,6 +118,7 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
<Button <Button
className="rb:py-0! rb:px-1! rb:text-[12px]!" className="rb:py-0! rb:px-1! rb:text-[12px]!"
size="small" size="small"
onClick={() => handleCopy(typeof vo.content === 'object' && vo.content?.input ? JSON.stringify(vo.content.input, null, 2) : '{}')}
>{t('common.copy')}</Button> >{t('common.copy')}</Button>
</div> </div>
<div className="rb:max-h-40 rb:overflow-auto"> <div className="rb:max-h-40 rb:overflow-auto">
@@ -133,35 +138,44 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
return ( return (
<Collapse <Collapse
key={vo.node_id} key={vo.node_id}
bordered={false}
className="rb:bg-[#F6F6F6]"
items={[{ items={[{
key: vo.node_id, key: vo.node_id,
label: <div className={clsx("rb:flex rb:justify-between rb:items-center", getStatus(vo.status))}> label: <div className={clsx("rb:flex rb:justify-between rb:items-center")}>
<div className="rb:flex rb:items-center rb:gap-1 rb:flex-1"> <Flex gap={6} align="center" className="rb:flex-1!">
{vo.icon && <div className={`rb:size-4 rb:bg-cover ${vo.icon}`} />} {vo.icon && <div className={`rb:size-6 rb:bg-cover ${vo.icon}`} />}
<div className="rb:wrap-break-word rb:line-clamp-1">{vo.node_name}</div> <div className="rb:wrap-break-word rb:line-clamp-1 rb:font-medium">{vo.node_name}</div>
</div> </Flex>
<span> <Flex align="center" gap={8} className="rb:text-[12px]">
{typeof vo.elapsed_time == 'number' && <>{vo.elapsed_time?.toFixed(3)}ms</>} {typeof vo.elapsed_time == 'number' && <>{vo.elapsed_time?.toFixed(3)}ms</>}
{vo.status === 'completed' ? <CheckCircleFilled className="rb:ml-1" /> : vo.status === 'failed' ? <CloseCircleFilled className="rb:ml-1" /> : <LoadingOutlined className="rb:ml-1" />} {vo.status === 'completed'
</span> ? <CheckCircleFilled className={`rb:mr-1 ${getStatus(vo.status)}`} />
: vo.status === 'failed'
? <CloseCircleFilled className={`rb:mr-1 ${getStatus(vo.status)}`} />
: <LoadingOutlined className={`rb:mr-1 ${getStatus(vo.status)}`} />
}
</Flex>
</div>, </div>,
className: styles.collapseItem, className: styles.collapseItem,
children: ( children: (
<Space size={8} direction="vertical" className="rb:w-full!"> <Flex gap={8} vertical>
{/* Display error message for failed nodes */} {/* Display error message for failed nodes */}
{vo.status === 'failed' &&
<div className={clsx("rb:bg-[#F0F3F8] rb:rounded-md", getStatus(vo.status))}> {item.error &&
<div className="rb:py-2 rb:px-3 rb:flex rb:justify-between rb:items-center rb:text-[12px]"> <RbAlert color="orange" className="rb:pb-0!">
{t(`workflow.error`)} <Flex vertical className="rb:w-full!">
<Button <Flex align="center" justify="space-between">
className="rb:py-0! rb:px-1! rb:text-[12px]!" {t(`workflow.error`)}
size="small" <Button
>{t('common.copy')}</Button> className="rb:py-0! rb:px-1! rb:text-[12px]!"
</div> size="small"
<div className="rb:pb-2 rb:px-3 rb:max-h-40 rb:overflow-auto"> onClick={() => handleCopy(vo.content?.error || '')}
>{t('common.copy')}</Button>
</Flex>
<Markdown content={vo.content?.error || ''} /> <Markdown content={vo.content?.error || ''} />
</div> </Flex>
</div> </RbAlert>
} }
{/* Display navigation to nested cycles if subContent exists */} {/* Display navigation to nested cycles if subContent exists */}
{vo.subContent?.length > 0 && ( {vo.subContent?.length > 0 && (
@@ -172,12 +186,13 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
)} )}
{/* Display input and output data as JSON code blocks */} {/* Display input and output data as JSON code blocks */}
{['input', 'output'].map(key => ( {['input', 'output'].map(key => (
<div key={key} className="rb:bg-[#F0F3F8] rb:rounded-md"> <div key={key} className="rb:bg-[#EBEBEB] rb:rounded-lg">
<div className="rb:py-2 rb:px-3 rb:flex rb:justify-between rb:items-center rb:text-[12px]"> <div className="rb:py-2 rb:px-3 rb:flex rb:justify-between rb:items-center rb:text-[12px]">
{isLoop ? t(`workflow.runtime.${key}_cycle_vars`) : t(`workflow.${key}`)} {isLoop ? t(`workflow.runtime.${key}_cycle_vars`) : t(`workflow.${key}`)}
<Button <Button
className="rb:py-0! rb:px-1! rb:text-[12px]!" className="rb:py-0! rb:px-1! rb:text-[12px]!"
size="small" size="small"
onClick={() => handleCopy(typeof vo.content === 'object' && vo.content?.[key] ? JSON.stringify(vo.content[key], null, 2) : '{}')}
>{t('common.copy')}</Button> >{t('common.copy')}</Button>
</div> </div>
<div className="rb:max-h-40 rb:overflow-auto"> <div className="rb:max-h-40 rb:overflow-auto">
@@ -186,55 +201,80 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
value={typeof vo.content === 'object' && vo.content?.[key] ? JSON.stringify(vo.content[key], null, 2) : '{}'} value={typeof vo.content === 'object' && vo.content?.[key] ? JSON.stringify(vo.content[key], null, 2) : '{}'}
needCopy={false} needCopy={false}
showLineNumbers={true} showLineNumbers={true}
background="#EBEBEB"
/> />
</div> </div>
</div> </div>
))} ))}
</Space> </Flex>
) )
}]} }]}
/> />
) )
})} })}
</Space> </Flex>
} }
return <div className={clsx("rb:bg-[#FBFDFF] rb:rounded-md rb:py-2 rb:px-3 ", getStatus('failed'))}> return <div className={clsx("rb:bg-[#FBFDFF] rb:rounded-md rb:py-2 rb:px-3 ", getStatus('failed'))}>
<Markdown content={list || ''} /> <Markdown content={list || ''} />
</div> </div>
} }
/** Copy value to clipboard and show success message */
const handleCopy = (value: string) => {
copy(value)
message.success(t('common.copySuccess'))
}
return ( return (
<div key={index} className="rb:min-w-100 rb:max-w-full rb:mb-2"> <div
<Collapse key={index}
className={styles[item.status || 'default']} className={clsx("rb:mb-4 rb-border rb:rounded-xl rb:px-4 rb:pt-3 rb:bg-white rb:max-w-full", {
items={[{ 'rb:hover:bg-[#F6F6F6] rb:w-64': !expanded
key: 0, })}
label: <div className={getStatus(item.status)}> >
{item.status === 'completed' ? <CheckCircleFilled className="rb:mr-1" /> : item.status === 'failed' ? <CloseCircleFilled className="rb:mr-1" /> : <LoadingOutlined className="rb:mr-1" />} <Flex align="center" justify="space-between" className="rb:font-medium rb:pb-3!">
{t('application.workflow')} <span className="rb:font-medium rb:leading-5">
</div>, {item.status === 'completed'
className: styles.collapseItem, ? <CheckCircleFilled className={`rb:mr-1 ${getStatus(item.status)}`} />
children: ( : item.status === 'failed'
detail ? <CloseCircleFilled className={`rb:mr-1 ${getStatus(item.status)}`} />
? ( : <LoadingOutlined className={`rb:mr-1 ${getStatus(item.status)}`} />
<div className="rb:bg-[#FBFDFF] rb:rounded-md"> }
<Button type="link" icon={<ArrowLeftOutlined />} onClick={() => setDetail(null)} className="rb:px-0! rb:text-[12px]!"> {t('application.workflow')}
{t('common.return')} </span>
</Button> <Flex
{renderDetailChild(detail.subContent)} align="center"
</div> justify="center"
) className={clsx("rb:size-6.5 rb:cursor-pointer rb-border rb:rounded-lg", {
: <> 'rb:hover:bg-[#F6F6F6]!': expanded
{item.error && })}
<div className={clsx("rb:bg-[#FBFDFF] rb:rounded-md rb:py-2 rb:px-3 rb:mb-2 rb:-mt-4", getStatus('failed'))}> onClick={() => { setExpanded(v => !v); setDetail(null) }}
<Markdown content={item.error} /> >
</div> <div
} className={clsx("rb:size-4 rb:bg-cover", {
{renderChild(item.subContent)} 'rb:bg-[url("@/assets/images/conversation/compress.svg")]': expanded,
</> 'rb:bg-[url("@/assets/images/conversation/expand.svg")]': !expanded
})}
/>
</Flex>
</Flex>
{expanded && (
detail
? (
<div className="rb:bg-[#FBFDFF] rb:rounded-md rb:mb-4">
<Button type="link" icon={<ArrowLeftOutlined />} onClick={() => setDetail(null)} className="rb:px-0! rb:text-[12px]!">
{t('common.return')}
</Button>
{renderDetailChild(detail.subContent)}
</div>
) )
}]} : <div className="rb:mb-4">
/> {item.error &&
<RbAlert color="orange" className="rb:pb-0! rb:mb-2!"><Markdown content={item.error} /></RbAlert>
}
{renderChild(item.subContent)}
</div>
)}
</div> </div>
) )
} }

View File

@@ -21,7 +21,9 @@
line-height: 16px; line-height: 16px;
} }
.collapse-item:global(.ant-collapse-item>.ant-collapse-header) { .collapse-item:global(.ant-collapse-item>.ant-collapse-header) {
padding: 8px 12px; padding: 8px 16px 8px 10px;
display: flex;
align-items: center;
} }
.collapse-item:global(.ant-collapse-item>.ant-collapse-header .ant-collapse-expand-icon) { .collapse-item:global(.ant-collapse-item>.ant-collapse-header .ant-collapse-expand-icon) {
height: 16px; height: 16px;
@@ -45,4 +47,7 @@
} }
.collapse-item :global(.ant-collapse .ant-collapse-content>.ant-collapse-content-box) { .collapse-item :global(.ant-collapse .ant-collapse-content>.ant-collapse-content-box) {
padding: 0 4px 4px 4px; padding: 0 4px 4px 4px;
}
:global(.ant-collapse-borderless)>.collapse-item:global(.ant-collapse-item>.ant-collapse-content>.ant-collapse-content-box) {
padding: 0 12px 12px 12px;
} }