返回 AiToEarn
index.tsx
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 原生图片语法 ![alt](url)
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
655 lines Plain Text