feat: add avatars for user and assistant in chat message list
This commit is contained in:
BIN
src/assets/images/ai_avatar.png
Normal file
BIN
src/assets/images/ai_avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
BIN
src/assets/images/me_avatar.png
Normal file
BIN
src/assets/images/me_avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user