feat: add avatars for user and assistant in chat message list

This commit is contained in:
duanshuwen
2026-04-22 22:37:26 +08:00
parent ea1fd18e6f
commit d915fcd61a
3 changed files with 168 additions and 52 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1,7 +1,10 @@
import { memo, useEffect, useRef } from 'react';
import { memo, useEffect, useState, useRef } from 'react';
import { Check, ChevronRight, Copy, ImageIcon, Paperclip, Sparkles } from 'lucide-react';
import type { ChatMessageItem } from './types';
import { useI18n } from '../../i18n';
import ChatEmptyState from './ChatEmptyState';
import aiAvatar from '../../assets/images/ai_avatar.png';
import meAvatar from '../../assets/images/me_avatar.png';
type ChatMessageListProps = {
messages: ChatMessageItem[];
@@ -9,6 +12,83 @@ type ChatMessageListProps = {
showWelcomeState?: boolean;
};
function cn(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ');
}
function AttachmentPreview({
messageRole,
attachment,
}: {
messageRole: ChatMessageItem['role'];
attachment: NonNullable<ChatMessageItem['attachments']>[number];
}) {
const isImage = attachment.mimeType.startsWith('image/');
const imageSizeClass = messageRole === 'user' ? 'h-24 w-24' : 'h-28 w-28';
if (isImage && attachment.preview) {
return (
<div className={cn(
'overflow-hidden rounded-[18px] border border-[#E1D8C9] bg-[#F3ECDD] shadow-[0_8px_18px_rgba(90,76,50,0.06)]',
'dark:border-white/10 dark:bg-white/5',
)}
>
<img
alt={attachment.fileName}
className={cn(imageSizeClass, 'object-cover')}
src={attachment.preview}
/>
</div>
);
}
return (
<div className="inline-flex max-w-full items-center gap-2 rounded-full border border-[#DDD3C3] bg-[#F3ECDD] px-3 py-2 text-xs text-[#625A4D] shadow-[0_6px_14px_rgba(90,76,50,0.04)] dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
{isImage ? <ImageIcon className="h-3.5 w-3.5 shrink-0" /> : <Paperclip className="h-3.5 w-3.5 shrink-0" />}
<span className="truncate">{attachment.fileName}</span>
</div>
);
}
function AssistantMeta({
content,
time,
}: {
content: string;
time: string;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
if (!content.trim() || !navigator?.clipboard?.writeText) {
return;
}
await navigator.clipboard.writeText(content);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
};
return (
<div className="flex w-full items-center justify-between px-1 text-xs text-[#1D2129] opacity-0 transition-opacity duration-200 group-hover:opacity-100 dark:text-gray-500">
<span>{time}</span>
{content.trim() ? (
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center rounded-full text-[#1D2129] transition-colors hover:bg-[#ECE3D3] hover:text-[#1F1F1F] dark:text-gray-500 dark:hover:bg-white/8 dark:hover:text-gray-200"
onClick={() => {
void handleCopy();
}}
aria-label="Copy response"
title="Copy response"
>
{copied ? <Check className="h-3.5 w-3.5 text-[#2B7FFF]" /> : <Copy className="h-3.5 w-3.5" />}
</button>
) : null}
</div>
);
}
function ChatMessageList({ messages, loading, showWelcomeState }: ChatMessageListProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const { t } = useI18n();
@@ -18,13 +98,13 @@ function ChatMessageList({ messages, loading, showWelcomeState }: ChatMessageLis
const container = containerRef.current;
if (!container) return;
container.scrollTop = container.scrollHeight;
}, [messages]);
}, [loading, messages]);
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden p-6">
<div ref={containerRef} className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto pr-1">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-6 dark:bg-[#161618] sm:px-6">
<div ref={containerRef} className="mx-auto flex min-h-0 w-full flex-1 flex-col gap-5 overflow-y-auto pr-1">
{loading ? (
<div className="rounded-[18px] border border-dashed border-[#BEDBFF] bg-[#EFF6FF] px-4 py-3 text-sm text-[#525866] dark:border-[#2a2a2d] dark:bg-[#1f1f22] dark:text-gray-400">
<div className="w-full rounded-[18px] border border-dashed border-[#DDD3C3] bg-[#EFE7D8] px-4 py-3 text-sm text-[#645C50] dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
{t('conversation.messageList.loading')}
</div>
) : null}
@@ -32,61 +112,97 @@ function ChatMessageList({ messages, loading, showWelcomeState }: ChatMessageLis
{messages.map((message) => (
<article
key={message.id}
className={[
'flex gap-3 rounded-[18px] border p-4',
message.role === 'assistant'
? 'border-[#E5E8EE] bg-[#f8fbff] dark:border-[#2a2a2d] dark:bg-[#1f1f22]'
: 'border-[#dfeaf6] bg-white dark:border-[#2a2a2d] dark:bg-[#232327]',
].join(' ')}
className={cn(
'group flex w-full items-start gap-3',
message.role === 'user' ? 'justify-end' : 'justify-start',
)}
>
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-full bg-[#eff6ff] text-sm font-bold text-[#2B7FFF] dark:bg-[#222225]">
{message.role === 'assistant'
? t('conversation.messageList.assistantBadge')
: t('conversation.messageList.userBadge')}
{message.role === 'assistant' ? (
<div className="mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-full">
<img className="h-full w-full object-cover" src={aiAvatar} alt="aiAvatar" />
</div>
) : null}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-[#171717] dark:text-gray-100">{message.name}</div>
<div className="text-xs text-[#99A0AE] dark:text-gray-500">{message.time}</div>
</div>
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-[#525866] dark:text-gray-300">{message.content}</p>
{message.attachments && message.attachments.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{message.attachments.map((attachment, index) => {
const attachmentKey = attachment.filePath || `${attachment.fileName}-${index}`;
const isImage = attachment.mimeType.startsWith('image/') && Boolean(attachment.preview);
if (isImage && attachment.preview) {
return (
<div
key={attachmentKey}
className="overflow-hidden rounded-xl border border-[#E5E8EE] bg-white dark:border-[#2a2a2d] dark:bg-[#232327]"
<div className={cn(
'min-w-0 flex flex-col gap-2',
message.role === 'user' ? 'max-w-[72%] items-end' : 'w-full max-w-[78%] items-start',
)}
>
<img
alt={attachment.fileName}
className="h-24 w-24 object-cover"
src={attachment.preview}
{message.role === 'assistant' && message.isStreaming ? (
<>
<div className="px-1 text-xs text-[#1D2129] dark:text-gray-500">
{t('conversation.messageList.streaming')}
</div>
<button
type="button"
className="flex w-full items-center gap-2 rounded-lg border border-[#DDD3C3] bg-[#ECE3D3] px-4 py-2.5 text-left text-[13px] font-medium text-[#5F574A] shadow-[0_4px_14px_rgba(90,76,50,0.03)] dark:border-white/10 dark:bg-white/5 dark:text-gray-300"
>
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
<span>Thinking</span>
</button>
</>
) : null}
{message.role === 'user' && message.attachments && message.attachments.length > 0 ? (
<div className="flex flex-wrap justify-end gap-2">
{message.attachments.map((attachment, index) => (
<AttachmentPreview
key={attachment.filePath || `${attachment.fileName}-${index}`}
attachment={attachment}
messageRole={message.role}
/>
))}
</div>
);
}
) : null}
return (
{message.content ? (
<div
key={attachmentKey}
className="rounded-full border border-[#E5E8EE] px-3 py-1.5 text-xs text-[#525866] dark:border-[#2a2a2d] dark:text-gray-300"
className={cn(
'max-w-full rounded-lg px-5 py-3.5 text-sm',
message.role === 'user'
? 'bg-[#F7F8FA] text-[#1D2129]'
: 'w-full border border-[#E1D8C9] text-[#1F1F1F] dark:border-white/10 dark:bg-white/5 dark:text-gray-100',
message.isError
? 'border border-[#F3C7CB] bg-[#FFF2F3] text-[#B42318] dark:border-[#4B2229] dark:bg-[#2B1C1F] dark:text-[#FFB4BF]'
: null,
)}
>
{attachment.fileName}
</div>
);
})}
<p className={cn(
'whitespace-pre-wrap break-words',
message.role === 'user' ? 'leading-7' : 'leading-8',
)}
>
{message.content}
</p>
</div>
) : null}
{message.isStreaming ? (
<div className="mt-3 text-xs text-[#2B7FFF]">{t('conversation.messageList.streaming')}</div>
) : null}
{message.role === 'assistant' && message.attachments && message.attachments.length > 0 ? (
<div className="flex flex-wrap gap-2">
{message.attachments.map((attachment, index) => (
<AttachmentPreview
key={attachment.filePath || `${attachment.fileName}-${index}`}
attachment={attachment}
messageRole={message.role}
/>
))}
</div>
) : null}
{message.role === 'assistant' ? (
<AssistantMeta content={message.content} time={message.time} />
) : (
<div className="px-1 text-xs text-[#1D2129] opacity-0 transition-opacity duration-200 group-hover:opacity-100 dark:text-gray-500">
{message.time}
</div>
)}
</div>
{message.role === 'user' ? (
<div className="mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-full">
<img className="h-full w-full object-cover" src={meAvatar} alt="meAvatar" />
</div>
) : null}
</article>
))}
</div>