| 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 |