第一次提交
This commit is contained in:
149
app-instance/frontend/components/chat-workbench/MessageList.tsx
Normal file
149
app-instance/frontend/components/chat-workbench/MessageList.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Bot, Loader2, Paperclip, User } from 'lucide-react';
|
||||
|
||||
import type { ChatMessage } from '@/types';
|
||||
import { getAccessToken, getFileUrl } from '@/lib/api';
|
||||
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
function AuthImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
||||
const [blobUrl, setBlobUrl] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const token = getAccessToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
let revoke: string | null = null;
|
||||
fetch(src, { headers })
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
revoke = URL.createObjectURL(blob);
|
||||
setBlobUrl(revoke);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
if (revoke) URL.revokeObjectURL(revoke);
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
if (!blobUrl) return <div className="w-32 h-32 bg-muted animate-pulse rounded" />;
|
||||
return <img src={blobUrl} alt={alt} className={className} loading="lazy" decoding="async" />;
|
||||
}
|
||||
|
||||
function MessageBubble({ message }: { message: ChatMessage }) {
|
||||
const isUser = message.role === 'user';
|
||||
const textContent = typeof message.content === 'string' ? message.content : String(message.content || '');
|
||||
|
||||
return (
|
||||
<div className={`flex gap-3 ${isUser ? 'justify-end' : ''}`}>
|
||||
{!isUser && (
|
||||
<div className="w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Bot className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`rounded-xl px-4 py-3 max-w-[88%] shadow-sm ${
|
||||
isUser
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-card border border-border/80'
|
||||
}`}
|
||||
>
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className="mb-2 space-y-2">
|
||||
{message.attachments.map((att) => {
|
||||
const fileUrl = getFileUrl(att.file_id);
|
||||
if (att.content_type.startsWith('image/')) {
|
||||
return (
|
||||
<a key={att.file_id} href={fileUrl} target="_blank" rel="noopener noreferrer">
|
||||
<AuthImage
|
||||
src={fileUrl}
|
||||
alt={att.name}
|
||||
className="max-w-xs max-h-60 rounded border border-border/50 cursor-pointer hover:opacity-90"
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
key={att.file_id}
|
||||
href={fileUrl}
|
||||
download={att.name}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm ${
|
||||
isUser
|
||||
? 'bg-primary-foreground/10 hover:bg-primary-foreground/20'
|
||||
: 'bg-muted hover:bg-muted/80'
|
||||
}`}
|
||||
>
|
||||
<Paperclip className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="truncate">{att.name}</span>
|
||||
{att.size && (
|
||||
<span className="text-xs opacity-70 flex-shrink-0">
|
||||
{att.size > 1024 * 1024
|
||||
? `${(att.size / 1024 / 1024).toFixed(1)}MB`
|
||||
: `${(att.size / 1024).toFixed(0)}KB`}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUser ? (
|
||||
<p className="text-sm whitespace-pre-wrap">{textContent}</p>
|
||||
) : (
|
||||
<MarkdownContent content={textContent} />
|
||||
)}
|
||||
</div>
|
||||
{isUser && (
|
||||
<div className="w-7 h-7 rounded-full bg-secondary flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<User className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
isThinking,
|
||||
messagesEndRef,
|
||||
viewportRef,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
isThinking: boolean;
|
||||
messagesEndRef: React.RefObject<HTMLDivElement>;
|
||||
viewportRef: React.RefObject<HTMLDivElement>;
|
||||
}) {
|
||||
return (
|
||||
<ScrollArea className="h-full px-4" viewportRef={viewportRef}>
|
||||
<div className="max-w-4xl mx-auto py-4 space-y-4">
|
||||
{messages.length === 0 && !isThinking && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Bot className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium">Boardware Genius</p>
|
||||
<p className="text-sm">发送消息开始对话</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
<MessageBubble key={`${msg.role}:${msg.timestamp || i}:${i}`} message={msg} />
|
||||
))}
|
||||
|
||||
{isThinking && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground px-1">
|
||||
<Bot className="w-5 h-5" />
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm">思考中...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user