返回 AiToEarn
index.tsx
1 /**
2 * ChatMessageList - 消息列表组件
3 * 负责渲染消息列表和回到底部按钮
4 */
5 'use client'
6
7 import type { IDisplayMessage, IWorkflowStep } from '@/store/agent'
8 import { ArrowDown } from 'lucide-react'
9 import { useState } from 'react'
10 import { useTransClient } from '@/app/i18n/client'
11 import { ChatMessage } from '@/components/Chat/ChatMessage'
12 import { RatingModal } from '@/components/Chat/Rating'
13 import { Button } from '@/components/ui/button'
14 import { StarRating } from '@/components/ui/star-rating'
15 import { toast } from '@/utils/ui/toast'
16
17 export interface IChatMessageListProps {
18 /** 消息列表 */
19 messages: IDisplayMessage[]
20 /** 工作流步骤(用于最后一条 AI 消息) */
21 workflowSteps: IWorkflowStep[]
22 /** 是否正在生成 */
23 isGenerating: boolean
24 /** 消息容器 ref */
25 containerRef: React.RefObject<HTMLDivElement>
26 /** 底部占位 ref */
27 bottomRef: React.RefObject<HTMLDivElement>
28 /** 是否显示回到底部按钮 */
29 showScrollButton: boolean
30 /** 滚动事件处理 */
31 onScroll: () => void
32 /** 点击回到底部 */
33 onScrollToBottom: () => void
34 /** 回到底部按钮文案 */
35 scrollToBottomText: string
36 /** 任务 ID */
37 taskId?: string
38 /** 任务评分(null 表示未评价) */
39 rating?: number | null
40 /** 评分保存成功回调 */
41 onRatingSaved?: () => void
42 }
43
44 export function ChatMessageList({
45 messages,
46 workflowSteps,
47 isGenerating,
48 containerRef,
49 bottomRef,
50 showScrollButton,
51 onScroll,
52 onScrollToBottom,
53 scrollToBottomText,
54 taskId,
55 rating,
56 onRatingSaved,
57 }: IChatMessageListProps) {
58 const { t } = useTransClient('chat')
59 const [ratingOpen, setRatingOpen] = useState(false)
60 const [selectedRating, setSelectedRating] = useState<number | null>(null)
61
62 // 过滤出用户和 AI 消息
63 const filteredMessages = messages.filter(
64 (message): message is IDisplayMessage & { role: 'user' | 'assistant' } =>
65 message.role === 'user' || message.role === 'assistant',
66 )
67
68 // 判断是否显示评分 UI
69 const hasUserMessage = filteredMessages.some(m => m.role === 'user')
70 const hasAssistantMessage = filteredMessages.some(m => m.role === 'assistant')
71 const isNotRated = rating === null || rating === undefined
72 const showRatingUI
73 = hasUserMessage && hasAssistantMessage && isNotRated && !isGenerating && taskId
74
75 // 处理星星点击
76 const handleStarClick = (value: number) => {
77 setSelectedRating(value)
78 setRatingOpen(true)
79 }
80
81 // 处理评分保存成功
82 const handleRatingSaved = () => {
83 setRatingOpen(false)
84 setSelectedRating(null)
85 toast.success(t('rating.submitSuccess'))
86 onRatingSaved?.()
87 }
88
89 return (
90 <div className="flex-1 relative overflow-hidden">
91 {/* 消息列表 - 外层负责滚动,内层限宽居中 */}
92 <div ref={containerRef} onScroll={onScroll} className="h-full overflow-y-auto">
93 <div className="max-w-6xl mx-auto px-4 pt-4 pb-4 flex gap-4 flex-col">
94 {filteredMessages.map((message, index) => {
95 // 判断是否为最后一条 assistant 消息(用于显示工作流)
96 const isLastAssistant
97 = message.role === 'assistant' && index === filteredMessages.length - 1
98
99 return (
100 <ChatMessage
101 key={message.id}
102 role={message.role}
103 content={message.content}
104 medias={message.medias}
105 status={message.status}
106 errorMessage={message.errorMessage}
107 createdAt={message.createdAt}
108 steps={message.steps}
109 workflowSteps={isLastAssistant && isGenerating ? workflowSteps : undefined}
110 actions={message.actions}
111 publishFlows={message.publishFlows}
112 isGenerating={isLastAssistant && isGenerating}
113 />
114 )
115 })}
116
117 {/* 如果正在生成但还没有 assistant 消息,显示一个空的 AI 消息 */}
118 {isGenerating && messages.every(m => m.role === 'user') && (
119 <ChatMessage
120 role="assistant"
121 content=""
122 status="streaming"
123 workflowSteps={workflowSteps}
124 isGenerating={true}
125 />
126 )}
127
128 {/* 评分引导 UI */}
129 {showRatingUI && (
130 <div className="flex flex-col items-start gap-3 py-6">
131 <p className="text-sm text-muted-foreground">{t('rating.feedbackPrompt')}</p>
132 <StarRating onChange={handleStarClick} size="md" />
133 </div>
134 )}
135
136 {/* 底部占位元素 */}
137 <div ref={bottomRef} />
138 </div>
139 </div>
140
141 {/* 回到底部按钮 */}
142 {showScrollButton && (
143 <div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 animate-in fade-in slide-in-from-bottom-2 duration-200">
144 <Button
145 variant="secondary"
146 size="sm"
147 onClick={onScrollToBottom}
148 className="rounded-full shadow-lg bg-background/95 backdrop-blur-sm hover:bg-background border border-border gap-1.5 px-4 transition-all hover:shadow-xl"
149 >
150 <ArrowDown className="w-4 h-4" />
151 <span className="text-sm">{scrollToBottomText}</span>
152 </Button>
153 </div>
154 )}
155
156 {/* 评分弹窗 */}
157 {taskId && (
158 <RatingModal
159 taskId={taskId}
160 open={ratingOpen}
161 defaultRating={selectedRating}
162 onClose={() => setRatingOpen(false)}
163 onSaved={handleRatingSaved}
164 />
165 )}
166 </div>
167 )
168 }
169
169 lines Plain Text