| 1 | /** |
| 2 | * ChatMessage - 聊天消息气泡组件 |
| 3 | * 功能:显示用户消息或AI回复,支持媒体附件、Markdown渲染、多步骤工作流展示 |
| 4 | * 每个步骤都有独立的工作流展示区域,支持自动展开/收起当前活跃步骤 |
| 5 | */ |
| 6 | |
| 7 | 'use client' |
| 8 | |
| 9 | import type { Components } from 'react-markdown' |
| 10 | import type { IUploadedMedia } from '../MediaUpload' |
| 11 | import type { MediaPreviewItem } from '@/components/common/MediaPreview' |
| 12 | import type { IActionCard, IMessageStep, IPublishFlowData, IWorkflowStep } from '@/store/agent' |
| 13 | import { AlertCircle, Loader2, Play } from 'lucide-react' |
| 14 | import { useCallback, useMemo, useState } from 'react' |
| 15 | import ReactMarkdown from 'react-markdown' |
| 16 | import rehypeRaw from 'rehype-raw' |
| 17 | import remarkGfm from 'remark-gfm' |
| 18 | import { useTransClient } from '@/app/i18n/client' |
| 19 | import MediaGallery from '@/components/Chat/ChatMessage/MediaGallery' |
| 20 | import WorkflowSection from '@/components/Chat/ChatMessage/WorkflowComponents' |
| 21 | import MediaPreview from '@/components/common/MediaPreview' |
| 22 | import { cn } from '@/utils/className' |
| 23 | import { getOssUrl } from '@/utils/oss' |
| 24 | import { ActionCard } from '../ActionCard' |
| 25 | import styles from './ChatMessage.module.scss' |
| 26 | import PluginPublishCard from './PluginPublishCard' |
| 27 | import PublishDetailCard from './PublishDetailCard' |
| 28 | |
| 29 | /** 过滤用户消息中的系统追加信息(草稿箱等) */ |
| 30 | function filterSystemPromoInfo(content: string): string { |
| 31 | return content.replace(/<<<SYSTEM_PROMO>>>[\s\S]*?<<<END_PROMO>>>/g, '').trim() |
| 32 | } |
| 33 | |
| 34 | /** 判断 URL 是否为视频链接 */ |
| 35 | function isVideoUrl(url: string): boolean { |
| 36 | if (!url) |
| 37 | return false |
| 38 | const videoExtensions = ['.mp4', '.webm', '.mov', '.avi', '.mkv', '.m4v', '.wmv', '.flv', '.ogv'] |
| 39 | const lowerUrl = url.toLowerCase().split('?')[0] // 移除查询参数 |
| 40 | return videoExtensions.some(ext => lowerUrl.endsWith(ext)) |
| 41 | } |
| 42 | |
| 43 | /** 判断 URL 是否为图片链接 */ |
| 44 | function isImageUrl(url: string): boolean { |
| 45 | if (!url) |
| 46 | return false |
| 47 | const imageExtensions = [ |
| 48 | '.jpg', |
| 49 | '.jpeg', |
| 50 | '.png', |
| 51 | '.gif', |
| 52 | '.webp', |
| 53 | '.bmp', |
| 54 | '.svg', |
| 55 | '.ico', |
| 56 | '.avif', |
| 57 | ] |
| 58 | const lowerUrl = url.toLowerCase().split('?')[0] // 移除查询参数 |
| 59 | return imageExtensions.some(ext => lowerUrl.endsWith(ext)) |
| 60 | } |
| 61 | |
| 62 | /** 根据 URL 获取媒体类型 */ |
| 63 | function getMediaTypeFromUrl(url: string): 'video' | 'image' { |
| 64 | return isVideoUrl(url) ? 'video' : 'image' |
| 65 | } |
| 66 | |
| 67 | export type { IWorkflowStep } |
| 68 | |
| 69 | export interface IChatMessageProps { |
| 70 | /** 消息角色 */ |
| 71 | role: 'user' | 'assistant' |
| 72 | /** 消息内容 */ |
| 73 | content: string |
| 74 | /** 媒体附件 */ |
| 75 | medias?: IUploadedMedia[] |
| 76 | /** 消息状态 */ |
| 77 | status?: 'pending' | 'streaming' | 'done' | 'error' |
| 78 | /** 错误信息 */ |
| 79 | errorMessage?: string |
| 80 | /** 创建时间 */ |
| 81 | createdAt?: number |
| 82 | /** 消息步骤列表(仅 assistant 消息使用) */ |
| 83 | steps?: IMessageStep[] |
| 84 | /** 工作流步骤列表(兼容旧接口,用于无steps时的显示) */ |
| 85 | workflowSteps?: IWorkflowStep[] |
| 86 | /** Action 卡片列表(用于显示可交互的 action) */ |
| 87 | actions?: IActionCard[] |
| 88 | /** 发布流程数据列表(用于显示 PublishDetailCard) */ |
| 89 | publishFlows?: IPublishFlowData[] |
| 90 | /** 是否正在生成(用于最后一条AI消息显示思考状态) */ |
| 91 | isGenerating?: boolean |
| 92 | /** 自定义类名 */ |
| 93 | className?: string |
| 94 | } |
| 95 | |
| 96 | // Workflow 子组件已拆分到 `WorkflowComponents`,这里直接使用导出的组件 |
| 97 | |
| 98 | /** |
| 99 | * MessageStepContent - 单个消息步骤的内容 |
| 100 | * 每个步骤都有自己的工作流展示区域 |
| 101 | */ |
| 102 | interface IMessageStepContentProps { |
| 103 | /** 步骤数据 */ |
| 104 | step: IMessageStep |
| 105 | /** 是否为最后一个步骤 */ |
| 106 | isLast: boolean |
| 107 | /** 消息是否正在流式输出 */ |
| 108 | isStreaming: boolean |
| 109 | } |
| 110 | |
| 111 | function MessageStepContent({ |
| 112 | step, |
| 113 | isLast, |
| 114 | isStreaming, |
| 115 | onOpenPreview, |
| 116 | }: IMessageStepContentProps & { onOpenPreview?: (url: string) => void }) { |
| 117 | const hasWorkflow = step.workflowSteps && step.workflowSteps.length > 0 |
| 118 | // 当前步骤是否活跃:是最后一个步骤且消息正在流式输出 |
| 119 | const isActiveStep = isLast && isStreaming |
| 120 | // 自定义 Markdown 组件 - 处理视频/图片链接渲染为预览模式 |
| 121 | |
| 122 | const markdownComponents: Components = useMemo( |
| 123 | () => ({ |
| 124 | a: ({ href, children }) => { |
| 125 | // 处理视频链接 |
| 126 | if (href && isVideoUrl(href)) { |
| 127 | const videoUrl = getOssUrl(href) |
| 128 | return ( |
| 129 | <span className="block my-3"> |
| 130 | <button |
| 131 | type="button" |
| 132 | onClick={() => onOpenPreview?.(href)} |
| 133 | className="relative w-56 h-40 rounded-lg overflow-hidden border border-border bg-muted cursor-pointer" |
| 134 | > |
| 135 | <video |
| 136 | src={videoUrl} |
| 137 | className="w-full h-full object-cover" |
| 138 | preload="metadata" |
| 139 | muted |
| 140 | /> |
| 141 | <span className="absolute inset-0 flex items-center justify-center pointer-events-none"> |
| 142 | <Play className="w-6 h-6 text-white/90" /> |
| 143 | </span> |
| 144 | </button> |
| 145 | </span> |
| 146 | ) |
| 147 | } |
| 148 | // 处理图片链接 |
| 149 | if (href && isImageUrl(href)) { |
| 150 | const imageUrl = getOssUrl(href) |
| 151 | return ( |
| 152 | <span className="block my-3"> |
| 153 | <button |
| 154 | type="button" |
| 155 | onClick={() => onOpenPreview?.(href)} |
| 156 | className="relative w-56 rounded-lg overflow-hidden border border-border bg-muted cursor-pointer" |
| 157 | > |
| 158 | <img |
| 159 | src={imageUrl} |
| 160 | alt={typeof children === 'string' ? children : 'image'} |
| 161 | className="w-full h-auto max-h-40 object-contain" |
| 162 | /> |
| 163 | </button> |
| 164 | </span> |
| 165 | ) |
| 166 | } |
| 167 | return ( |
| 168 | <a |
| 169 | href={href} |
| 170 | target="_blank" |
| 171 | rel="noopener noreferrer" |
| 172 | className="text-primary hover:underline" |
| 173 | > |
| 174 | {children} |
| 175 | </a> |
| 176 | ) |
| 177 | }, |
| 178 | // 处理 HTML video 标签(AI 返回的 <video src="..." controls></video>) |
| 179 | video: ({ src }) => { |
| 180 | if (!src) |
| 181 | return null |
| 182 | const videoUrl = getOssUrl(src) |
| 183 | return ( |
| 184 | <span className="block my-3"> |
| 185 | <button |
| 186 | type="button" |
| 187 | onClick={() => onOpenPreview?.(src)} |
| 188 | className="relative w-56 h-40 rounded-lg overflow-hidden border border-border bg-muted cursor-pointer" |
| 189 | > |
| 190 | <video |
| 191 | src={videoUrl} |
| 192 | className="w-full h-full object-cover" |
| 193 | preload="metadata" |
| 194 | muted |
| 195 | /> |
| 196 | <span className="absolute inset-0 flex items-center justify-center pointer-events-none"> |
| 197 | <Play className="w-6 h-6 text-white/90" /> |
| 198 | </span> |
| 199 | </button> |
| 200 | </span> |
| 201 | ) |
| 202 | }, |
| 203 | // 处理 Markdown 原生图片语法  |
| 204 | img: ({ src, alt }) => { |
| 205 | if (!src) |
| 206 | return null |
| 207 | |
| 208 | // 判断是否为视频 URL |
| 209 | if (isVideoUrl(src)) { |
| 210 | // 渲染视频(复用 video 组件处理器的逻辑) |
| 211 | const videoUrl = getOssUrl(src) |
| 212 | return ( |
| 213 | <span className="block my-3"> |
| 214 | <button |
| 215 | type="button" |
| 216 | onClick={() => onOpenPreview?.(src)} |
| 217 | className="relative w-56 h-40 rounded-lg overflow-hidden border border-border bg-muted cursor-pointer" |
| 218 | > |
| 219 | <video |
| 220 | src={videoUrl} |
| 221 | className="w-full h-full object-cover" |
| 222 | preload="metadata" |
| 223 | muted |
| 224 | /> |
| 225 | <span className="absolute inset-0 flex items-center justify-center pointer-events-none"> |
| 226 | <Play className="w-6 h-6 text-white/90" /> |
| 227 | </span> |
| 228 | </button> |
| 229 | </span> |
| 230 | ) |
| 231 | } |
| 232 | |
| 233 | // 渲染图片(保持原有逻辑) |
| 234 | const imageUrl = getOssUrl(src) |
| 235 | return ( |
| 236 | <span className="block my-3"> |
| 237 | <button |
| 238 | type="button" |
| 239 | onClick={() => onOpenPreview?.(src)} |
| 240 | className="relative w-56 rounded-lg overflow-hidden border border-border bg-muted cursor-pointer" |
| 241 | > |
| 242 | <img |
| 243 | src={imageUrl} |
| 244 | alt={alt || 'image'} |
| 245 | className="w-full h-auto max-h-40 object-contain" |
| 246 | /> |
| 247 | </button> |
| 248 | </span> |
| 249 | ) |
| 250 | }, |
| 251 | p: ({ children }) => { |
| 252 | const content = String(children) |
| 253 | // 匹配视频和图片 URL |
| 254 | const mediaUrlRegex |
| 255 | = /(https?:\/\/\S+(?:\.mp4|\.webm|\.mov|\.jpg|\.jpeg|\.png|\.gif|\.webp)\S*)/gi |
| 256 | const matches = content.match(mediaUrlRegex) |
| 257 | |
| 258 | if (matches && matches.length > 0) { |
| 259 | const parts = content.split(mediaUrlRegex) |
| 260 | return ( |
| 261 | <span className="block"> |
| 262 | {parts.map((part, index) => { |
| 263 | if (matches.includes(part)) { |
| 264 | const mediaUrl = getOssUrl(part) |
| 265 | const isVideo = isVideoUrl(part) |
| 266 | return ( |
| 267 | <span key={index} className="block my-3"> |
| 268 | <button |
| 269 | type="button" |
| 270 | onClick={() => onOpenPreview?.(part)} |
| 271 | className={cn( |
| 272 | 'relative rounded-lg overflow-hidden border border-border bg-muted cursor-pointer', |
| 273 | isVideo ? 'w-56 h-40' : 'w-56', |
| 274 | )} |
| 275 | > |
| 276 | {isVideo ? ( |
| 277 | <> |
| 278 | <video |
| 279 | src={mediaUrl} |
| 280 | className="w-full h-full object-cover" |
| 281 | preload="metadata" |
| 282 | muted |
| 283 | /> |
| 284 | <span className="absolute inset-0 flex items-center justify-center pointer-events-none"> |
| 285 | <Play className="w-6 h-6 text-white/90" /> |
| 286 | </span> |
| 287 | </> |
| 288 | ) : ( |
| 289 | <img |
| 290 | src={mediaUrl} |
| 291 | alt="media" |
| 292 | className="w-full h-auto max-h-40 object-contain" |
| 293 | /> |
| 294 | )} |
| 295 | </button> |
| 296 | </span> |
| 297 | ) |
| 298 | } |
| 299 | return part ? ( |
| 300 | <span key={index} className="block mb-2 last:mb-0"> |
| 301 | {part} |
| 302 | </span> |
| 303 | ) : null |
| 304 | })} |
| 305 | </span> |
| 306 | ) |
| 307 | } |
| 308 | |
| 309 | return <p className="mb-2 last:mb-0">{children}</p> |
| 310 | }, |
| 311 | }), |
| 312 | [onOpenPreview], |
| 313 | ) |
| 314 | |
| 315 | return ( |
| 316 | <div |
| 317 | className={cn( |
| 318 | styles.messageStep, |
| 319 | // 非最后一个步骤时,在底部添加分隔线 |
| 320 | !isLast && 'border-b border-border/60 pb-3 mb-3', |
| 321 | )} |
| 322 | > |
| 323 | {/* 步骤文本内容 */} |
| 324 | {step.content && ( |
| 325 | <div className={cn('text-sm leading-relaxed text-foreground', styles.markdownContent)}> |
| 326 | <ReactMarkdown |
| 327 | remarkPlugins={[remarkGfm]} |
| 328 | rehypePlugins={[rehypeRaw]} |
| 329 | components={markdownComponents} |
| 330 | > |
| 331 | {step.content} |
| 332 | </ReactMarkdown> |
| 333 | </div> |
| 334 | )} |
| 335 | |
| 336 | {/* 如果该步骤包含媒体(image/video),在步骤内容下方渲染 MediaGallery */} |
| 337 | {step.medias && step.medias.length > 0 && ( |
| 338 | <div className="mt-3"> |
| 339 | <MediaGallery |
| 340 | medias={step.medias as any} |
| 341 | onPreviewByUrl={url => onOpenPreview?.(url)} |
| 342 | /> |
| 343 | </div> |
| 344 | )} |
| 345 | |
| 346 | {/* 该步骤的工作流展示 */} |
| 347 | {hasWorkflow && ( |
| 348 | <WorkflowSection workflowSteps={step.workflowSteps!} isActive={isActiveStep} /> |
| 349 | )} |
| 350 | </div> |
| 351 | ) |
| 352 | } |
| 353 | |
| 354 | /** |
| 355 | * ChatMessage - 聊天消息气泡组件 |
| 356 | */ |
| 357 | export function ChatMessage({ |
| 358 | role, |
| 359 | content, |
| 360 | medias = [], |
| 361 | status = 'done', |
| 362 | errorMessage, |
| 363 | steps = [], |
| 364 | workflowSteps = [], |
| 365 | actions = [], |
| 366 | publishFlows = [], |
| 367 | isGenerating = false, |
| 368 | className, |
| 369 | }: IChatMessageProps) { |
| 370 | const { t } = useTransClient('chat') |
| 371 | const isUser = role === 'user' |
| 372 | const isStreaming = status === 'streaming' || status === 'pending' |
| 373 | |
| 374 | // 媒体预览状态(统一使用全局 MediaPreview 组件) |
| 375 | const [previewIndex, setPreviewIndex] = useState<number | null>(null) |
| 376 | // 来自文本/链接的外部预览(例如 Markdown 中的视频链接)——优先级高于附件数组预览 |
| 377 | const [externalPreviewItems, setExternalPreviewItems] = useState<MediaPreviewItem[] | null>(null) |
| 378 | |
| 379 | const previewableMedias = useMemo( |
| 380 | () => medias.filter(m => m.type === 'image' || m.type === 'video'), |
| 381 | [medias], |
| 382 | ) |
| 383 | |
| 384 | const previewItems = useMemo( |
| 385 | () => |
| 386 | previewableMedias.map(m => ({ |
| 387 | type: m.type === 'video' ? ('video' as const) : ('image' as const), |
| 388 | src: getOssUrl(m.url), |
| 389 | title: m.name || m.file?.name, |
| 390 | })), |
| 391 | [previewableMedias], |
| 392 | ) |
| 393 | |
| 394 | const openPreviewWithUrl = useCallback((url: string) => { |
| 395 | if (!url) |
| 396 | return |
| 397 | setExternalPreviewItems([ |
| 398 | { |
| 399 | type: getMediaTypeFromUrl(url), |
| 400 | src: getOssUrl(url), |
| 401 | title: undefined, |
| 402 | }, |
| 403 | ]) |
| 404 | }, []) |
| 405 | |
| 406 | // 处理消息步骤:如果有 steps 则使用 steps,否则从 content 生成单个步骤 |
| 407 | const displaySteps = useMemo(() => { |
| 408 | if (steps && steps.length > 0) { |
| 409 | return steps |
| 410 | } |
| 411 | // 没有 steps 时,尝试从 content 解析多个段落作为步骤 |
| 412 | // 使用双换行分割内容为多个步骤 |
| 413 | if (content) { |
| 414 | const paragraphs = content.split(/\n{2,}/).filter(p => p.trim()) |
| 415 | if (paragraphs.length > 1) { |
| 416 | return paragraphs.map((p, i) => ({ |
| 417 | id: `legacy-step-${i}`, |
| 418 | content: p.trim(), |
| 419 | workflowSteps: i === paragraphs.length - 1 ? workflowSteps : [], |
| 420 | isActive: i === paragraphs.length - 1 && isStreaming, |
| 421 | timestamp: Date.now(), |
| 422 | })) |
| 423 | } |
| 424 | } |
| 425 | // 单个步骤 |
| 426 | return [ |
| 427 | { |
| 428 | id: 'single-step', |
| 429 | content, |
| 430 | workflowSteps, |
| 431 | isActive: isStreaming, |
| 432 | timestamp: Date.now(), |
| 433 | }, |
| 434 | ] |
| 435 | }, [steps, content, workflowSteps, isStreaming]) |
| 436 | |
| 437 | return ( |
| 438 | <div className={cn('flex', isUser ? 'justify-end' : 'justify-start', className)}> |
| 439 | {/* 消息内容 */} |
| 440 | <div className={cn('flex flex-col gap-2 min-w-0', isUser ? 'items-end' : 'items-start')}> |
| 441 | {/* 媒体附件(统一使用 MediaGallery) */} |
| 442 | {medias.length > 0 && ( |
| 443 | <MediaGallery |
| 444 | medias={medias} |
| 445 | size={isUser && medias.length === 1 ? 'large' : 'default'} |
| 446 | onPreviewByIndex={(originalIndex) => { |
| 447 | // 将原始 medias 索引映射到 previewableMedias 的索引 |
| 448 | const target = medias[originalIndex] |
| 449 | const mapped = previewableMedias.findIndex(m => m === target) |
| 450 | if (mapped >= 0) |
| 451 | setPreviewIndex(mapped) |
| 452 | }} |
| 453 | onPreviewByUrl={(url) => { |
| 454 | openPreviewWithUrl(url) |
| 455 | }} |
| 456 | /> |
| 457 | )} |
| 458 | |
| 459 | {/* AI 消息:多步骤渲染 - 只有有实际内容或工作流时才显示 */} |
| 460 | {!isUser |
| 461 | && displaySteps.length > 0 |
| 462 | && displaySteps.some(s => s.content?.trim() || s.workflowSteps?.length) && ( |
| 463 | <div className="w-full min-w-0"> |
| 464 | {displaySteps.map((step, index) => ( |
| 465 | <MessageStepContent |
| 466 | key={step.id} |
| 467 | step={step} |
| 468 | isLast={index === displaySteps.length - 1} |
| 469 | isStreaming={isStreaming} |
| 470 | onOpenPreview={openPreviewWithUrl} |
| 471 | /> |
| 472 | ))} |
| 473 | |
| 474 | {/* 发布详情卡片 - 放在消息框内部 */} |
| 475 | {publishFlows && publishFlows.length > 0 && ( |
| 476 | <div className="mt-4 pt-4 border-t border-border/50 space-y-3"> |
| 477 | {publishFlows.map(flow => ( |
| 478 | <PublishDetailCard |
| 479 | key={flow.flowId} |
| 480 | flowId={flow.flowId} |
| 481 | platform={flow.platform} |
| 482 | initialData={flow.initialData} |
| 483 | /> |
| 484 | ))} |
| 485 | </div> |
| 486 | )} |
| 487 | |
| 488 | {/* 思考中状态 - 在最后一条AI消息底部显示 */} |
| 489 | {isGenerating && ( |
| 490 | <div className="mt-4 pt-4 border-t border-border/50"> |
| 491 | <div className="flex items-center gap-3 px-1"> |
| 492 | {/* 优雅的波点动画 */} |
| 493 | <div className="flex items-center gap-1 flex-shrink-0"> |
| 494 | <div |
| 495 | className="w-1.5 h-1.5 rounded-full bg-blue-500" |
| 496 | style={{ |
| 497 | animation: 'bounceDots 1.4s ease-in-out infinite both', |
| 498 | animationDelay: '0s', |
| 499 | }} |
| 500 | > |
| 501 | </div> |
| 502 | <div |
| 503 | className="w-1.5 h-1.5 rounded-full bg-purple-500" |
| 504 | style={{ |
| 505 | animation: 'bounceDots 1.4s ease-in-out infinite both', |
| 506 | animationDelay: '0.2s', |
| 507 | }} |
| 508 | > |
| 509 | </div> |
| 510 | <div |
| 511 | className="w-1.5 h-1.5 rounded-full bg-pink-500" |
| 512 | style={{ |
| 513 | animation: 'bounceDots 1.4s ease-in-out infinite both', |
| 514 | animationDelay: '0.4s', |
| 515 | }} |
| 516 | > |
| 517 | </div> |
| 518 | </div> |
| 519 | |
| 520 | {/* 思考文字 */} |
| 521 | <span |
| 522 | className="text-sm font-medium bg-linear-to-r from-blue-500 via-purple-500 to-pink-500 bg-clip-text text-transparent" |
| 523 | style={{ |
| 524 | animation: 'thinkingGlow 1.8s ease-in-out infinite', |
| 525 | display: 'inline-block', |
| 526 | }} |
| 527 | > |
| 528 | {t('message.thinking')} |
| 529 | </span> |
| 530 | </div> |
| 531 | <style> |
| 532 | {` |
| 533 | @keyframes thinkingGlow { |
| 534 | 0%, 100% { |
| 535 | opacity: 0.7; |
| 536 | filter: hue-rotate(0deg) brightness(1); |
| 537 | } |
| 538 | 50% { |
| 539 | opacity: 1; |
| 540 | filter: hue-rotate(180deg) brightness(1.2); |
| 541 | } |
| 542 | } |
| 543 | @keyframes bounceDots { |
| 544 | 0%, 80%, 100% { |
| 545 | transform: scale(0.8); |
| 546 | opacity: 0.5; |
| 547 | } |
| 548 | 40% { |
| 549 | transform: scale(1.2); |
| 550 | opacity: 1; |
| 551 | } |
| 552 | } |
| 553 | `} |
| 554 | </style> |
| 555 | </div> |
| 556 | )} |
| 557 | </div> |
| 558 | )} |
| 559 | |
| 560 | {/* 用户消息:简单渲染(过滤系统追加的隐藏信息) */} |
| 561 | {isUser && content && ( |
| 562 | <div |
| 563 | className={cn( |
| 564 | 'px-4 py-3 rounded-2xl text-sm leading-relaxed', |
| 565 | 'bg-muted text-foreground rounded-br-md whitespace-pre-wrap break-words', |
| 566 | )} |
| 567 | style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }} |
| 568 | > |
| 569 | {filterSystemPromoInfo(content)} |
| 570 | </div> |
| 571 | )} |
| 572 | |
| 573 | {/* 加载状态(无步骤内容时显示默认 loading) */} |
| 574 | {!isUser && status === 'pending' && displaySteps.every(s => !s.content) && ( |
| 575 | <div className="flex items-center gap-2 rounded-2xl bg-card rounded-bl-md"> |
| 576 | <Loader2 className="w-4 h-4 text-primary animate-spin" /> |
| 577 | <span className="text-sm text-muted-foreground">Thinking...</span> |
| 578 | </div> |
| 579 | )} |
| 580 | |
| 581 | {/* 流式输出状态(无内容时显示) */} |
| 582 | {!isUser && status === 'streaming' && displaySteps.every(s => !s.content) && ( |
| 583 | <div className="flex items-center gap-2 rounded-2xl bg-card rounded-bl-md"> |
| 584 | <div className="flex gap-1"> |
| 585 | <span |
| 586 | className="w-2 h-2 bg-primary rounded-full animate-bounce" |
| 587 | style={{ animationDelay: '0ms' }} |
| 588 | /> |
| 589 | <span |
| 590 | className="w-2 h-2 bg-primary rounded-full animate-bounce" |
| 591 | style={{ animationDelay: '150ms' }} |
| 592 | /> |
| 593 | <span |
| 594 | className="w-2 h-2 bg-primary rounded-full animate-bounce" |
| 595 | style={{ animationDelay: '300ms' }} |
| 596 | /> |
| 597 | </div> |
| 598 | </div> |
| 599 | )} |
| 600 | |
| 601 | {/* 错误状态 */} |
| 602 | {status === 'error' && ( |
| 603 | <div className="flex items-center gap-2 px-4 py-3 rounded-2xl bg-destructive/10 text-destructive rounded-bl-md"> |
| 604 | <AlertCircle className="w-4 h-4" /> |
| 605 | <span className="text-sm">{errorMessage || 'Generation failed, please retry'}</span> |
| 606 | </div> |
| 607 | )} |
| 608 | |
| 609 | {/* Action 卡片 */} |
| 610 | {!isUser && actions && actions.length > 0 && ( |
| 611 | <div className="w-full space-y-3 mt-2"> |
| 612 | {actions.map((action, index) => { |
| 613 | // 插件平台(小红书/抖音)使用自动发布倒计时卡片 |
| 614 | const isPluginPublish |
| 615 | = action.type === 'navigateToPublish' |
| 616 | && (action.platform === 'xhs' || action.platform === 'douyin') |
| 617 | |
| 618 | if (isPluginPublish) { |
| 619 | return ( |
| 620 | <PluginPublishCard |
| 621 | key={`plugin-publish-${index}-${action.platform || ''}`} |
| 622 | action={action} |
| 623 | /> |
| 624 | ) |
| 625 | } |
| 626 | |
| 627 | return ( |
| 628 | <ActionCard |
| 629 | key={`action-${index}-${action.type}-${action.platform || ''}`} |
| 630 | action={action} |
| 631 | /> |
| 632 | ) |
| 633 | })} |
| 634 | </div> |
| 635 | )} |
| 636 | </div> |
| 637 | |
| 638 | {/* 全局媒体预览(图片 / 视频) */} |
| 639 | {(previewItems.length > 0 || externalPreviewItems) && ( |
| 640 | <MediaPreview |
| 641 | open={previewIndex !== null || externalPreviewItems !== null} |
| 642 | items={externalPreviewItems ?? previewItems} |
| 643 | initialIndex={externalPreviewItems ? 0 : (previewIndex ?? 0)} |
| 644 | onClose={() => { |
| 645 | setPreviewIndex(null) |
| 646 | setExternalPreviewItems(null) |
| 647 | }} |
| 648 | /> |
| 649 | )} |
| 650 | </div> |
| 651 | ) |
| 652 | } |
| 653 | |
| 654 | export default ChatMessage |
| 655 |