Files
LangBot/web/src/app/home/monitoring/components/MessageContentRenderer.tsx
Junyan Qin f6e7983890 refactor(web): replace all hardcoded SVG icons with lucide-react
Unify icon usage across the entire frontend by replacing 67 hardcoded
SVG icons with lucide-react components across ~25 files. This improves
consistency, maintainability, and reduces bundle duplication.

Key replacements:
- Sidebar nav: Zap, LayoutDashboard, Bot, Workflow, BookMarked, etc.
- MCP forms: Loader2, XCircle, Trash2
- Monitoring: Sparkles, MessageSquare, CheckCircle2, RefreshCw, etc.
- Cards: Clock, Star, Workflow, Hexagon, Puzzle, Github, etc.
- Misc: Paperclip, AudioLines, CloudUpload, Layers, Heart, Smile

Zero hardcoded <svg> tags remain in .tsx files.
2026-05-04 21:33:03 +08:00

214 lines
5.8 KiB
TypeScript

import React, { useState } from 'react';
import { Paperclip, AudioLines } from 'lucide-react';
import {
MessageChainComponent,
Image as ImageComponent,
Plain,
At,
Voice,
Quote,
} from '@/app/infra/entities/message';
import ImagePreviewDialog from '@/app/home/pipelines/components/debug-dialog/ImagePreviewDialog';
interface MessageContentRendererProps {
content: string;
maxLines?: number;
}
export function MessageContentRenderer({
content,
maxLines = 3,
}: MessageContentRendererProps) {
const [previewImageUrl, setPreviewImageUrl] = useState<string>('');
const [showImagePreview, setShowImagePreview] = useState(false);
// Try to parse content as message_chain JSON
const parseContent = (content: string): MessageChainComponent[] | null => {
try {
const parsed = JSON.parse(content);
if (Array.isArray(parsed) && parsed.length > 0 && parsed[0].type) {
return parsed as MessageChainComponent[];
}
return null;
} catch {
return null;
}
};
const renderMessageComponent = (
component: MessageChainComponent,
index: number,
) => {
switch (component.type) {
case 'Plain':
return <span key={index}>{(component as Plain).text}</span>;
case 'At': {
const atComponent = component as At;
const displayName =
atComponent.display || atComponent.target?.toString() || '';
return (
<span
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 text-sm"
>
@{displayName}
</span>
);
}
case 'AtAll':
return (
<span
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 text-sm"
>
@All
</span>
);
case 'Image': {
const img = component as ImageComponent;
const imageUrl = img.url || (img.base64 ? img.base64 : '');
if (!imageUrl) {
return (
<span
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
>
[Image]
</span>
);
}
return (
<span key={index} className="inline-block align-middle mx-1">
<img
src={imageUrl}
alt="Message attachment"
className="w-20 h-20 object-cover rounded cursor-pointer hover:opacity-80 transition-opacity border"
onClick={(e) => {
e.stopPropagation();
setPreviewImageUrl(imageUrl);
setShowImagePreview(true);
}}
/>
</span>
);
}
case 'File': {
const file = component as MessageChainComponent & { name?: string };
return (
<span
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
>
<Paperclip className="w-3.5 h-3.5 mr-1" />
{file.name || 'File'}
</span>
);
}
case 'Voice': {
const voice = component as Voice;
return (
<span
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
>
<AudioLines className="w-3.5 h-3.5 mr-1" />
Voice{voice.length ? ` ${voice.length}s` : ''}
</span>
);
}
case 'Quote': {
const quote = component as Quote;
return (
<span
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm border-l-2 border-muted-foreground/50"
>
{quote.origin
?.filter((c) => (c as MessageChainComponent).type === 'Plain')
.map((c) => (c as MessageChainComponent as Plain).text)
.join('') || '[Quote]'}
</span>
);
}
case 'Source':
return null;
default:
return (
<span
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
>
[{component.type}]
</span>
);
}
};
const messageChain = parseContent(content);
// Determine line clamp class
const lineClampClass =
maxLines === 2
? 'line-clamp-2'
: maxLines === 3
? 'line-clamp-3'
: maxLines === 4
? 'line-clamp-4'
: '';
if (messageChain) {
// Filter out Source components as they render to null
const visibleComponents = messageChain.filter(
(component) => component.type !== 'Source',
);
// If no visible components, show placeholder
if (visibleComponents.length === 0) {
return (
<span className="text-muted-foreground italic">[Empty message]</span>
);
}
// Render as message chain
return (
<>
<div className={`${lineClampClass}`}>
{messageChain.map((component, index) =>
renderMessageComponent(component, index),
)}
</div>
<ImagePreviewDialog
open={showImagePreview}
imageUrl={previewImageUrl}
onClose={() => setShowImagePreview(false)}
/>
</>
);
}
// Handle empty plain text
if (
!content ||
content.trim() === '' ||
content === '[]' ||
content === '""'
) {
return (
<span className="text-muted-foreground italic">[Empty message]</span>
);
}
// Render as plain text
return <span className={lineClampClass}>{content}</span>;
}