Files
MemoryBear/web/src/views/Workflow/components/SingleNodeRun/index.tsx
2026-05-07 18:52:46 +08:00

342 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* @Author: ZhaoYing
* @Date: 2026-05-07 18:37:31
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-05-07 18:51:58
*/
import { type FC, useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Flex, Form, Input, InputNumber, Select, App, Checkbox } from 'antd'
import { Node } from '@antv/x6'
import copy from 'copy-to-clipboard'
import clsx from 'clsx'
import { nodeRun } from '@/api/application'
import CodeBlock from '@/components/Markdown/CodeBlock'
import RbCard from '@/components/RbCard/Card'
import styles from '../Properties/properties.module.css'
import ContextList from './ContextList'
import FileVarInput from './FileVarInput'
import type { Suggestion } from '../Editor/plugin/AutocompletePlugin'
import Markdown from '@/components/Markdown'
import RbAlert from '@/components/RbAlert'
interface RunResult {
status: 'completed' | 'failed' | 'running';
node_id?: string;
node_type?: string;
inputs?: Record<string, any>;
outputs?: any;
token_usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
elapsed_time?: number;
error?: string | null;
}
interface SingleNodeRunProps {
open: boolean;
onClose: () => void
selectedNode: Node
appId: string
variableList: Suggestion[]
}
const SingleNodeRun: FC<SingleNodeRunProps> = ({ open, onClose, selectedNode, appId, variableList }) => {
const { t } = useTranslation()
const { message } = App.useApp()
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<RunResult | null>(null)
const [isAutoRun, setIsAutoRun] = useState(false)
const nodeData = selectedNode?.getData() || {}
const nodeName = nodeData.name || t(`workflow.${nodeData.type}`)
const isLlm = nodeData.type === 'llm'
const hasContext = isLlm && nodeData.config.context.defaultValue
// Recursively collect all {{nodeId.var}} references from nodeData, excluding conv. vars
const extractVarRefs = (val: any, refs = new Set<string>()): Set<string> => {
if (typeof val === 'string') {
for (const m of val.matchAll(/\{\{([^}]+)\}\}/g))
if (!m[1].startsWith('conv.') && m[1] !== 'context') {
refs.add(m[1])
}
} else if (Array.isArray(val)) {
val.forEach(v => extractVarRefs(v, refs))
} else if (val && typeof val === 'object') {
Object.values(val).forEach(v => extractVarRefs(v, refs))
}
return refs
}
const varRefs = extractVarRefs(nodeData)
const visionInputRef = isLlm ? nodeData.config.vision_input?.defaultValue?.match(/\{\{([^}]+)\}\}/)?.[1] : undefined
const contextInputRef = isLlm ? nodeData.config.context?.defaultValue?.match(/\{\{([^}]+)\}\}/)?.[1] : undefined
const inputVars = variableList.filter(v => varRefs.has(v.value) && v.value !== visionInputRef && v.value !== contextInputRef)
const handleRun = () => {
form.validateFields()
.then((values) => {
const { inputs = {} } = values
console.log('values', values)
const params: Record<string, any> = {};
Object.keys(inputs).forEach(key => {
const value = inputs[key]
if (typeof value === 'object') {
params[key] = value.map((file: any) => {
if (file.url) {
return file
} else {
return {
type: file.type,
transfer_method: 'local_file',
upload_file_id: file.response.data.file_id
}
}
})
} else {
params[key] = value;
}
})
setLoading(true)
setResult({ status: 'running' })
if (hasContext) {
const contextValues: string[] = form.getFieldValue('context') || []
if (contextValues.length > 0) {
params['context'] = contextValues.map(item => { try { return JSON.parse(item) } catch { return item } })
}
}
nodeRun(appId, nodeData.id, { inputs: params, stream: false })
.then(res => {
setResult(res as RunResult)
})
.catch(err => {
setResult({ status: 'failed', error: err.message })
setLoading(false)
})
.finally(() => setLoading(false))
})
}
const handleCopy = (val: string) => {
copy(val)
message.success(t('common.copySuccess'))
}
const statusColor = result?.status === 'completed' ? '#369F21' : result?.status === 'failed' ? '#FF5D34' : '#5B6167'
useEffect(() => {
if (open) {
if (nodeData?.type === 'iteration' || inputVars.length < 1 && !hasContext && !(isLlm && nodeData?.config?.vision?.defaultValue)) {
setIsAutoRun(true)
}
}
}, [open, inputVars, isLlm, hasContext, nodeData?.type, nodeData?.config?.vision?.defaultValue])
useEffect(() => {
if (isAutoRun) {
handleRun()
}
}, [isAutoRun])
if (!open) return null
return (
// 与 Properties 完全相同的定位容器
<div className={clsx('rb:h-[calc(100vh-88px)] rb:w-90 rb:absolute rb:right-0 rb:top-0 rb:bottom-2.5 rb:z-1002', styles.properties)}>
{/* mask仅覆盖 header 以下的区域header 保持透明露出节点名 */}
<div
className="rb:absolute rb:inset-x-0 rb:bottom-0 rb:top-0 rb:rounded-xl rb:bg-[rgba(0,0,0,0.3)] rb:z-1002"
/>
{/* SingleNodeRun 卡片z-index 高于 mask */}
<div className="rb:absolute rb:inset-x-0 rb:top-25.5 rb:bottom-0 rb:z-1003">
<RbCard
title={`${t('workflow.testRun')} ${nodeName}`}
extra={
<div
className="rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/close.svg')]"
onClick={onClose}
/>
}
headerType="borderless"
headerClassName="rb:font-[MiSans-Bold] rb:font-bold rb:min-h-[48px]!"
className="rb:h-full! rb:hover:shadow-none!"
bodyClassName="rb:overflow-y-auto! rb:h-[calc(100%-48px)]! rb:px-3! rb:pt-0! rb:pb-3!"
>
<Form form={form} layout="vertical" size="small" className="rb:mb-0!">
<Flex vertical gap={12}>
{/* Variables */}
{nodeData?.type !== 'iteration' && inputVars.length > 0 && (
<Flex vertical gap={8}>
<div className="rb:text-[12px] rb:font-medium rb:text-[#5B6167]">{t('workflow.variables')}</div>
{inputVars.map(v => (
<Form.Item
key={v.value}
name={['inputs', v.value.replace('{{', '').replace('}}', '')]}
label={v.dataType.includes('boolean')
? null
: <Flex gap={4} align="center" className="rb:text-[12px]">
{v.nodeData?.icon && <div className={`rb:size-3.5 rb:bg-cover ${v.nodeData.icon}`} />}
<span className="rb:font-medium">{v.nodeData?.name}</span>
<span className="rb:text-[#5B6167]">/</span>
<span className="rb:text-[#1677ff]">{v.label}</span>
</Flex>
}
// rules={[{
// required: ['knowledge-retrieval', 'loop'].includes(nodeData.type) && !v.dataType.includes('boolean'),
// message: ['array[string]', 'array[number]'].includes(v.dataType) && Array.isArray(v.default) && v.default.length > 0 ? t('common.selectPlaceholder', { title: v.label }) : t('common.inputPlaceholder', { title: v.label })
// }]}
className="rb:mb-0!"
>
{['array[string]', 'array[number]'].includes(v.dataType) && Array.isArray(v.default) && v.default.length > 0
? <Select
placeholder={t('common.pleaseSelect')}
options={v.default.map((item: string) => ({ label: item, value: item }))}
/>
: v.dataType.includes('string') && nodeData.type === 'knowledge-retrieval'
? <Input.TextArea
placeholder={t('common.pleaseEnter')}
size="small"
/>
: v.dataType.includes('string')
? <Input
placeholder={t('common.pleaseEnter')}
size="small"
/>
: v.dataType.includes('number')
? <InputNumber
size="small"
placeholder={t('common.pleaseEnter')}
className="rb:w-full!"
onChange={(value) => form.setFieldValue(['retry', 'retry_interval'], value)}
/>
: v.dataType.includes('file')
? <FileVarInput name={['inputs', v.value.replace('{{', '').replace('}}', '')]} dataType={v.dataType} form={form} />
: v.dataType.includes('boolean')
? <Checkbox>
<Flex gap={4} align="center" className="rb:text-[12px]">
{v.nodeData?.icon && <div className={`rb:size-3.5 rb:bg-cover ${v.nodeData.icon}`} />}
<span className="rb:font-medium">{v.nodeData?.name}</span>
<span className="rb:text-[#5B6167]">/</span>
<span className="rb:text-[#1677ff]">{v.label}</span>
</Flex>
</Checkbox>
: null
}
</Form.Item>
))}
</Flex>
)}
{/* Context */}
{hasContext && <ContextList />}
{isLlm && nodeData?.config?.vision?.defaultValue && (() => {
const ref = nodeData.config.vision_input?.defaultValue
const visionVar = ref ? variableList.find(v => v.value === ref) : undefined
const dataType = visionVar?.dataType ?? 'array[file]'
// if (!visionVar) return null
console.log('visionVar', ref)
return (
<Form.Item
name={['inputs', ref.replace('{{', '').replace('}}', '')]}
label={t('workflow.config.llm.vision')}
className="rb:mb-0!"
>
<FileVarInput name={['inputs', ref.replace('{{', '').replace('}}', '')]} dataType={dataType} form={form} />
</Form.Item>
)
})()}
{/* Run button */}
{(!isAutoRun || result?.status) &&
<Button type="primary" block onClick={handleRun} loading={!result?.status && loading} disabled={loading}>
{result?.status ? t('workflow.reStartRun') : t('workflow.startRun')}
</Button>
}
{/* Status row */}
{result && (
<div className="rb:rounded-lg rb:border rb:border-[#E8E8E8] rb:p-3 rb:bg-[#F6FFF4]">
<Flex justify="space-between" align="start">
<Flex vertical align="start" gap={2}>
<span className="rb:text-[11px] rb:text-[#5B6167]">{t('workflow.status')}</span>
<span className="rb:font-medium rb:text-[13px]" style={{ color: statusColor }}>
{result.status?.toUpperCase()}
</span>
</Flex>
<Flex vertical align="start" gap={2}>
<span className="rb:text-[11px] rb:text-[#5B6167]">{t('workflow.elapsedTime')}</span>
{result.elapsed_time != null && <span className="rb:font-medium rb:text-[13px]">{result.elapsed_time?.toFixed(3)}ms</span>}
</Flex>
<Flex vertical gap={2} align="start">
<span className="rb:text-[11px] rb:text-[#5B6167]">{t('workflow.totalTokens')}</span>
{!loading && <span className="rb:font-medium rb:text-[13px]">{ result?.token_usage?.total_tokens || 0} Tokens</span>}
</Flex>
</Flex>
</div>
)}
{/* Input / Output code blocks */}
{result && (['inputs', 'process', 'outputs'] as const).map(key => {
if (nodeData.type !== 'http-request' && key === 'process') return null
const content = typeof result[key as keyof RunResult] === 'object' && result[key as keyof RunResult] ? JSON.stringify(result[key as keyof RunResult], null, 2) : result[key as keyof RunResult] ? result[key as keyof RunResult] : '{}'
return (
<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]">
{t(`workflow.${key}_result`)}
<Button
className="rb:py-0! rb:px-1! rb:text-[12px]!"
size="small"
onClick={() => handleCopy(content)}
>{t('common.copy')}</Button>
</div>
<div className="rb:max-h-40 rb:overflow-auto">
<CodeBlock
size="small"
value={content}
needCopy={false}
showLineNumbers={true}
background="#EBEBEB"
/>
</div>
</div>
)
})}
{/* Error */}
{result?.error && (
<RbAlert color="orange" className="rb:pb-0!">
<Flex vertical className="rb:w-full!">
<Flex align="center" justify="space-between">
{t(`workflow.error`)}
<Button
className="rb:py-0! rb:px-1! rb:text-[12px]!"
size="small"
onClick={() => handleCopy(result?.error || '')}
>{t('common.copy')}</Button>
</Flex>
<Markdown className="rb:wrap-break-word!" content={result?.error || ''} />
</Flex>
</RbAlert>
)}
</Flex>
</Form>
</RbCard>
</div>
</div>
)
}
export default SingleNodeRun