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 type { ChatMessageItem } from './types';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
import ChatEmptyState from './ChatEmptyState';
|
import ChatEmptyState from './ChatEmptyState';
|
||||||
|
import aiAvatar from '../../assets/images/ai_avatar.png';
|
||||||
|
import meAvatar from '../../assets/images/me_avatar.png';
|
||||||
|
|
||||||
type ChatMessageListProps = {
|
type ChatMessageListProps = {
|
||||||
messages: ChatMessageItem[];
|
messages: ChatMessageItem[];
|
||||||
@@ -9,6 +12,83 @@ type ChatMessageListProps = {
|
|||||||
showWelcomeState?: boolean;
|
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) {
|
function ChatMessageList({ messages, loading, showWelcomeState }: ChatMessageListProps) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -18,13 +98,13 @@ function ChatMessageList({ messages, loading, showWelcomeState }: ChatMessageLis
|
|||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
}, [messages]);
|
}, [loading, messages]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden p-6">
|
<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="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto pr-1">
|
<div ref={containerRef} className="mx-auto flex min-h-0 w-full flex-1 flex-col gap-5 overflow-y-auto pr-1">
|
||||||
{loading ? (
|
{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')}
|
{t('conversation.messageList.loading')}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -32,61 +112,97 @@ function ChatMessageList({ messages, loading, showWelcomeState }: ChatMessageLis
|
|||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<article
|
<article
|
||||||
key={message.id}
|
key={message.id}
|
||||||
className={[
|
className={cn(
|
||||||
'flex gap-3 rounded-[18px] border p-4',
|
'group flex w-full items-start gap-3',
|
||||||
message.role === 'assistant'
|
message.role === 'user' ? 'justify-end' : 'justify-start',
|
||||||
? 'border-[#E5E8EE] bg-[#f8fbff] dark:border-[#2a2a2d] dark:bg-[#1f1f22]'
|
)}
|
||||||
: 'border-[#dfeaf6] bg-white dark:border-[#2a2a2d] dark:bg-[#232327]',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
>
|
||||||
<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' ? (
|
||||||
{message.role === 'assistant'
|
<div className="mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-full">
|
||||||
? t('conversation.messageList.assistantBadge')
|
<img className="h-full w-full object-cover" src={aiAvatar} alt="aiAvatar" />
|
||||||
: t('conversation.messageList.userBadge')}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className={cn(
|
||||||
<div className="flex items-center justify-between gap-3">
|
'min-w-0 flex flex-col gap-2',
|
||||||
<div className="text-sm font-semibold text-[#171717] dark:text-gray-100">{message.name}</div>
|
message.role === 'user' ? 'max-w-[72%] items-end' : 'w-full max-w-[78%] items-start',
|
||||||
<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]"
|
|
||||||
>
|
>
|
||||||
<img
|
{message.role === 'assistant' && message.isStreaming ? (
|
||||||
alt={attachment.fileName}
|
<>
|
||||||
className="h-24 w-24 object-cover"
|
<div className="px-1 text-xs text-[#1D2129] dark:text-gray-500">
|
||||||
src={attachment.preview}
|
{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>
|
</div>
|
||||||
);
|
) : null}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
{message.content ? (
|
||||||
<div
|
<div
|
||||||
key={attachmentKey}
|
className={cn(
|
||||||
className="rounded-full border border-[#E5E8EE] px-3 py-1.5 text-xs text-[#525866] dark:border-[#2a2a2d] dark:text-gray-300"
|
'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}
|
<p className={cn(
|
||||||
</div>
|
'whitespace-pre-wrap break-words',
|
||||||
);
|
message.role === 'user' ? 'leading-7' : 'leading-8',
|
||||||
})}
|
)}
|
||||||
|
>
|
||||||
|
{message.content}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{message.isStreaming ? (
|
|
||||||
<div className="mt-3 text-xs text-[#2B7FFF]">{t('conversation.messageList.streaming')}</div>
|
{message.role === 'assistant' && message.attachments && message.attachments.length > 0 ? (
|
||||||
) : null}
|
<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>
|
</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>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user