返回 AiToEarn
WorkflowComponents.tsx
根目录 / project / aitoearn-web / src / components / Chat / ChatMessage / WorkflowComponents.tsx
1 'use client'
2
3 import type { IWorkflowStep } from '@/store/agent'
4 import { CheckCircle2, ChevronDown, ChevronRight, Loader2, Wrench } from 'lucide-react'
5 import { useEffect, useRef, useState } from 'react'
6 import { useTransClient } from '@/app/i18n/client'
7 import { cn } from '@/utils/className'
8 import styles from './ChatMessage.module.scss'
9 import { ToolDetailModal } from './ToolDetailModal'
10
11 export type { IWorkflowStep }
12
13 export interface IWorkflowSectionProps {
14 workflowSteps: IWorkflowStep[]
15 isActive?: boolean
16 defaultExpanded?: boolean
17 }
18
19 function formatToolName(name: string) {
20 return name.replace(/^mcp__\w+__/, '')
21 }
22
23 interface IWorkflowStepItemProps {
24 step: IWorkflowStep
25 /** 点击回调(仅 tool_call 类型可点击) */
26 onClick?: (step: IWorkflowStep) => void
27 }
28
29 function WorkflowStepItem({ step, onClick }: IWorkflowStepItemProps) {
30 const { t } = useTransClient('chat')
31 // tool_call 类型:有 result 字段表示已完成;其他类型:!isActive 表示已完成
32 const isCompleted = step.type === 'tool_call' ? !!step.result : !step.isActive
33 // 只有 tool_call 类型才可点击
34 const isClickable = step.type === 'tool_call' && !!onClick
35
36 // 不渲染 tool_result 类型(已合并到 tool_call 中)
37 if (step.type === 'tool_result') {
38 return null
39 }
40
41 const handleClick = () => {
42 if (isClickable) {
43 onClick(step)
44 }
45 }
46
47 return (
48 <div
49 className={cn(
50 'flex items-start gap-2 px-2 py-1.5 rounded-lg text-xs transition-colors',
51 step.isActive ? 'bg-primary/10' : 'bg-muted/80',
52 isClickable && 'cursor-pointer hover:bg-muted',
53 )}
54 onClick={handleClick}
55 role={isClickable ? 'button' : undefined}
56 tabIndex={isClickable ? 0 : undefined}
57 onKeyDown={
58 isClickable
59 ? (e) => {
60 if (e.key === 'Enter' || e.key === ' ') {
61 e.preventDefault()
62 handleClick()
63 }
64 }
65 : undefined
66 }
67 >
68 <div className="shrink-0 mt-0.5">
69 {step.isActive ? (
70 <Loader2 className="w-3 h-3 text-primary animate-spin" />
71 ) : isCompleted ? (
72 <CheckCircle2 className="w-3 h-3 text-success" />
73 ) : (
74 <Wrench className="w-3 h-3 text-muted-foreground/70" />
75 )}
76 </div>
77 <div className="flex-1 min-w-0">
78 <div
79 className={cn(
80 'font-medium truncate',
81 step.isActive ? 'text-primary' : isCompleted ? 'text-success' : 'text-muted-foreground',
82 )}
83 >
84 {step.type === 'tool_call'
85 ? `${formatToolName(step.toolName || 'Tool')}${isCompleted ? '' : '...'}`
86 : formatToolName(step.toolName || t('workflow.processing' as any))}
87 </div>
88 {step.content && (
89 <pre className="text-[10px] text-muted-foreground/70 mt-0.5 overflow-hidden text-ellipsis whitespace-nowrap">
90 {step.content.substring(0, 80)}
91 {step.content.length > 80 ? '...' : ''}
92 </pre>
93 )}
94 </div>
95 </div>
96 )
97 }
98
99 function WorkflowSection({ workflowSteps, isActive, defaultExpanded }: IWorkflowSectionProps) {
100 const { t } = useTransClient('chat')
101 const initialExpanded
102 = defaultExpanded !== undefined ? defaultExpanded : isActive !== undefined ? isActive : true
103 const [expanded, setExpanded] = useState(initialExpanded)
104 const [isNearBottom, setIsNearBottom] = useState(true)
105 const listRef = useRef<HTMLDivElement>(null)
106 // 工具详情弹框状态
107 const [selectedStep, setSelectedStep] = useState<IWorkflowStep | null>(null)
108
109 useEffect(() => {
110 if (isActive !== undefined) {
111 setExpanded(isActive)
112 }
113 }, [isActive])
114
115 // 监听滚动位置,判断用户是否在底部附近
116 const handleScroll = () => {
117 if (!listRef.current)
118 return
119 const { scrollTop, scrollHeight, clientHeight } = listRef.current
120 // 距离底部小于 20px 认为在底部附近
121 const nearBottom = scrollHeight - scrollTop - clientHeight < 20
122 setIsNearBottom(nearBottom)
123 }
124
125 // 工作流步骤变化时,只有在底部附近才自动滚动到底部
126 useEffect(() => {
127 if (listRef.current && expanded && isNearBottom) {
128 // 使用 setTimeout 确保 DOM 完全更新后再滚动
129 const timer = setTimeout(() => {
130 if (listRef.current) {
131 listRef.current.scrollTop = listRef.current.scrollHeight
132 }
133 }, 0)
134 return () => clearTimeout(timer)
135 }
136 }, [workflowSteps, expanded, isNearBottom])
137
138 const totalToolCalls = workflowSteps.filter(s => s.type === 'tool_call').length
139 // 统计有 result 的 tool_call 数量(已完成的工具调用)
140 const completedSteps = workflowSteps.filter(s => s.type === 'tool_call' && s.result).length
141 const activeStep = workflowSteps.find(s => s.isActive)
142
143 if (workflowSteps.length === 0) {
144 return null
145 }
146
147 return (
148 <div className={cn(styles.workflowSection, 'mt-2')}>
149 <button
150 onClick={() => setExpanded(!expanded)}
151 className={cn(
152 'flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs transition-all w-full',
153 isActive
154 ? 'bg-primary/10 text-primary hover:bg-primary/15 border border-primary/20'
155 : 'bg-background/60 text-muted-foreground hover:bg-muted border border-border/60',
156 )}
157 >
158 {isActive && activeStep ? (
159 <>
160 <Wrench className="w-3.5 h-3.5 animate-pulse" />
161 <span className="font-medium truncate flex-1 text-left">
162 {formatToolName(activeStep.toolName || t('workflow.processing' as any))}
163 </span>
164 <Loader2 className="w-3 h-3 animate-spin" />
165 </>
166 ) : (
167 <>
168 <CheckCircle2 className="w-3.5 h-3.5 text-success" />
169 <span className="font-medium flex-1 text-left">
170 {completedSteps}
171 /
172 {totalToolCalls}
173 {' '}
174 {t('workflow.toolCallCompleted' as any)}
175 </span>
176 </>
177 )}
178 <span className="shrink-0">
179 {expanded ? (
180 <ChevronDown className="w-3.5 h-3.5" />
181 ) : (
182 <ChevronRight className="w-3.5 h-3.5" />
183 )}
184 </span>
185 </button>
186
187 {expanded && (
188 <div
189 ref={listRef}
190 onScroll={handleScroll}
191 className={cn(
192 'pl-2 border-l-2 space-y-1 mt-1.5 ml-1 overflow-y-auto',
193 isActive ? 'border-primary/30' : 'border-border',
194 styles.workflowDetailList,
195 )}
196 >
197 {workflowSteps.map((step, index) => (
198 <WorkflowStepItem key={step.id || index} step={step} onClick={setSelectedStep} />
199 ))}
200 </div>
201 )}
202
203 {/* 工具详情弹框 */}
204 <ToolDetailModal
205 open={!!selectedStep}
206 onOpenChange={(open) => {
207 if (!open)
208 setSelectedStep(null)
209 }}
210 step={selectedStep}
211 />
212 </div>
213 )
214 }
215
216 export default WorkflowSection
217
217 lines Plain Text