- 重构 MiddlePanel 对话列表,按时间分组并优化交互 - 为 AccountList 和 AssetGallery 添加加载骨架屏 - 用确认对话框替换原生 confirm,统一交互体验 - 优化聊天消息组件的视觉样式和细节 - 添加 Escape 键快速返回账户列表导航 - 更新构建资源文件
178 lines
7.6 KiB
TypeScript
178 lines
7.6 KiB
TypeScript
import { useState, useCallback } from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
import { renderMarkdown } from '@/lib/markdown';
|
|
import { Bot, User, Copy, Trash2, ChevronDown, ChevronRight, RefreshCw, ArrowRight, Quote, Brain } from 'lucide-react';
|
|
import { ConfirmDialog } from '@/components/ui/confirm';
|
|
import { useConfirm } from '@/hooks/useConfirm';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import type { Message } from '@/types';
|
|
|
|
type MessageState = 'streaming' | 'thinking' | 'done';
|
|
|
|
interface Props {
|
|
message: Message;
|
|
isLast?: boolean;
|
|
isThinking?: boolean;
|
|
onRegenerate?: (msgId: string) => void;
|
|
onContinue?: () => void;
|
|
onQuote?: (content: string) => void;
|
|
onDelete?: (msgId: string) => void;
|
|
}
|
|
|
|
function getState(msg: Message, isLast: boolean, isThinking: boolean): MessageState {
|
|
if (msg.role === 'user') return 'done';
|
|
if (isThinking && isLast) return 'thinking';
|
|
if (!msg.content && !msg.reasoningContent) return 'streaming';
|
|
return 'done';
|
|
}
|
|
|
|
export function ChatMessage({ message, isLast, isThinking, onRegenerate, onContinue, onQuote, onDelete }: Props) {
|
|
const isUser = message.role === 'user';
|
|
const isTool = message.role === 'tool';
|
|
const [reasoningOpen, setReasoningOpen] = useState(false);
|
|
const { toast } = useToast();
|
|
const { confirmState, confirm, handleConfirm, handleCancel } = useConfirm();
|
|
const state = getState(message, !!isLast, !!isThinking);
|
|
|
|
const handleCopy = useCallback(() => {
|
|
navigator.clipboard.writeText(message.content).then(() => {
|
|
toast('已复制到剪贴板', 'success');
|
|
});
|
|
}, [message.content, toast]);
|
|
|
|
const handleDeleteClick = useCallback(async () => {
|
|
const ok = await confirm({
|
|
title: '删除消息',
|
|
description: '确定要删除这条消息吗?',
|
|
confirmText: '删除',
|
|
variant: 'danger',
|
|
});
|
|
if (ok) onDelete?.(message.id);
|
|
}, [message.id, onDelete, confirm]);
|
|
|
|
// Tool messages
|
|
if (isTool) {
|
|
let toolName = '';
|
|
try { const p = JSON.parse(message.content); toolName = p.tool || ''; } catch {}
|
|
return (
|
|
<div className="flex items-center gap-2 py-1 px-3">
|
|
<Bot size={13} className="text-zinc-400" />
|
|
<span className="text-xs text-zinc-400">工具调用: {toolName}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const showActions = state === 'done' && !isUser && message.content;
|
|
|
|
return (
|
|
<div className={cn('flex gap-3 mb-5 group/msg', isUser ? 'flex-row-reverse' : 'flex-row')}>
|
|
{/* Avatar */}
|
|
<div className={cn(
|
|
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 text-xs',
|
|
isUser
|
|
? 'bg-gradient-to-br from-indigo-500 to-indigo-600 text-white shadow-sm'
|
|
: 'bg-zinc-100 text-zinc-500'
|
|
)}>
|
|
{isUser ? <User size={14} /> : <Bot size={14} />}
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className={cn('flex-1 max-w-[78%]', isUser && 'flex flex-col items-end')}>
|
|
{/* Thinking indicator */}
|
|
{state === 'thinking' && !message.content && !message.reasoningContent && (
|
|
<div className="rounded-2xl px-4 py-3 bg-gradient-to-br from-indigo-50 to-zinc-50 border border-indigo-100/50 rounded-tl-sm">
|
|
<div className="flex items-center gap-2 text-zinc-400 text-sm">
|
|
<Brain size={14} className="animate-pulse text-indigo-400" />
|
|
<span>深度思考中...</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Reasoning block (collapsible) */}
|
|
{message.reasoningContent && (
|
|
<div className="mb-1.5 w-full">
|
|
<button
|
|
onClick={() => setReasoningOpen(!reasoningOpen)}
|
|
className="flex items-center gap-1.5 text-xs text-zinc-400 hover:text-zinc-600 transition-colors py-1"
|
|
>
|
|
{state === 'streaming' || state === 'thinking' ? (
|
|
<Brain size={12} className="animate-pulse text-indigo-400" />
|
|
) : (
|
|
<Brain size={12} />
|
|
)}
|
|
<span>{state === 'done' ? '深度思考' : '思考中...'}</span>
|
|
{state === 'done' && (
|
|
reasoningOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />
|
|
)}
|
|
</button>
|
|
{(reasoningOpen || state !== 'done') && (
|
|
<div className="mt-1 px-3 py-2.5 rounded-xl bg-gradient-to-br from-indigo-50/80 to-violet-50/40 border border-indigo-100/50 text-xs text-zinc-500 leading-relaxed max-h-60 overflow-y-auto whitespace-pre-wrap">
|
|
{message.reasoningContent}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Main content bubble */}
|
|
{!(message.content === '(调用工具)' && state === 'done' && message.reasoningContent) && (
|
|
<div className={cn(
|
|
'rounded-2xl px-4 py-2.5 text-sm leading-relaxed',
|
|
isUser
|
|
? 'bg-gradient-to-br from-indigo-500 to-indigo-600 text-white rounded-tr-sm shadow-sm'
|
|
: 'bg-zinc-50/80 text-zinc-700 border border-zinc-100 rounded-tl-sm'
|
|
)}>
|
|
{state === 'streaming' && !message.content ? (
|
|
<div className="flex items-center gap-1.5 text-zinc-400">
|
|
<span className="inline-block w-1.5 h-1.5 bg-indigo-300 rounded-full animate-pulse" />
|
|
<span className="inline-block w-1.5 h-1.5 bg-indigo-300 rounded-full animate-pulse [animation-delay:150ms]" />
|
|
<span className="inline-block w-1.5 h-1.5 bg-indigo-300 rounded-full animate-pulse [animation-delay:300ms]" />
|
|
</div>
|
|
) : isUser ? (
|
|
<p className="whitespace-pre-wrap">{message.content}</p>
|
|
) : (
|
|
<div className="markdown-body prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }} />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
{showActions && (
|
|
<div className="flex gap-0.5 mt-1 opacity-0 group-hover/msg:opacity-100 transition-opacity duration-200 px-1">
|
|
<button onClick={handleCopy}
|
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
|
<Copy size={10} />复制
|
|
</button>
|
|
<button onClick={() => onQuote?.(message.content)}
|
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
|
<Quote size={10} />引用
|
|
</button>
|
|
<button onClick={handleDeleteClick}
|
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] text-zinc-400 hover:text-red-500 hover:bg-red-50 transition-colors">
|
|
<Trash2 size={10} />删除
|
|
</button>
|
|
<button onClick={() => onRegenerate?.(message.id)}
|
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
|
<RefreshCw size={10} />重新生成
|
|
</button>
|
|
<button onClick={() => onContinue?.()}
|
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
|
<ArrowRight size={10} />继续
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<ConfirmDialog
|
|
open={confirmState.open}
|
|
title={confirmState.title}
|
|
description={confirmState.description}
|
|
confirmText={confirmState.confirmText}
|
|
cancelText={confirmState.cancelText}
|
|
variant={confirmState.variant}
|
|
onConfirm={handleConfirm}
|
|
onCancel={handleCancel}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|