fix(ui): 修复 React key 警告和 forwardRef 问题
- ChatMessage/SessionItem 使用 forwardRef 支持 AnimatePresence - useChat 为 message_received/done 事件生成唯一消息 ID - sessions API 为历史消息添加 ID 字段 - cli 添加 @types/inquirer 依赖
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
import { User, Bot, Copy, Check } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
import { useState, forwardRef } from 'react';
|
||||
import { cn } from '../utils/cn';
|
||||
import { fadeInUp, smoothTransition } from '../utils/animations';
|
||||
import { Markdown } from './Markdown';
|
||||
@@ -14,62 +14,65 @@ interface ChatMessageProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export function ChatMessage({ message }: ChatMessageProps) {
|
||||
const isUser = message.role === 'user';
|
||||
const [copied, setCopied] = useState(false);
|
||||
export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
|
||||
({ message }, ref) => {
|
||||
const isUser = message.role === 'user';
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(message.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(message.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={fadeInUp}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={smoothTransition}
|
||||
className={cn(
|
||||
'group flex gap-4 p-4 rounded-lg',
|
||||
isUser ? 'bg-gray-800' : 'bg-gray-800/50'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
variants={fadeInUp}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={smoothTransition}
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0',
|
||||
isUser ? 'bg-primary-600' : 'bg-green-600'
|
||||
'group flex gap-4 p-4 rounded-lg',
|
||||
isUser ? 'bg-gray-800' : 'bg-gray-800/50'
|
||||
)}
|
||||
>
|
||||
{isUser ? <User size={18} /> : <Bot size={18} />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-gray-400">
|
||||
{isUser ? 'You' : 'AI Assistant'}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 rounded text-gray-500 hover:text-gray-300 hover:bg-gray-700 transition-all"
|
||||
title="Copy message"
|
||||
>
|
||||
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="message-content text-gray-200">
|
||||
{isUser ? (
|
||||
// 用户消息:保持原样显示
|
||||
<div className="whitespace-pre-wrap break-words">{message.content}</div>
|
||||
) : (
|
||||
// AI 消息:Markdown 渲染
|
||||
<Markdown content={message.content} />
|
||||
<div
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0',
|
||||
isUser ? 'bg-primary-600' : 'bg-green-600'
|
||||
)}
|
||||
>
|
||||
{isUser ? <User size={18} /> : <Bot size={18} />}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-gray-400">
|
||||
{isUser ? 'You' : 'AI Assistant'}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 rounded text-gray-500 hover:text-gray-300 hover:bg-gray-700 transition-all"
|
||||
title="Copy message"
|
||||
>
|
||||
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="message-content text-gray-200">
|
||||
{isUser ? (
|
||||
// 用户消息:保持原样显示
|
||||
<div className="whitespace-pre-wrap break-words">{message.content}</div>
|
||||
) : (
|
||||
// AI 消息:Markdown 渲染
|
||||
<Markdown content={message.content} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
interface StreamingMessageProps {
|
||||
content: string;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 支持响应式:responsive=true 时桌面端固定显示,移动端抽屉式菜单
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, forwardRef } from 'react';
|
||||
import { Plus, MessageSquare, Trash2, Menu, X, MessageCircle } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { toast } from 'sonner';
|
||||
@@ -127,40 +127,43 @@ export function Sidebar({
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
// 会话列表项
|
||||
const SessionItem = ({ session }: { session: Session }) => (
|
||||
<motion.div
|
||||
layout
|
||||
variants={fadeInUp}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={smoothTransition}
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 p-3 rounded-lg cursor-pointer group',
|
||||
'hover:bg-gray-700 transition-colors',
|
||||
'active:bg-gray-600',
|
||||
currentSessionId === session.id && 'bg-gray-700'
|
||||
)}
|
||||
>
|
||||
<MessageSquare size={18} className="text-gray-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate">
|
||||
{session.name || `Chat ${session.id.slice(0, 8)}`}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{session.messageCount} messages</div>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={(e) => handleDelete(session.id, e)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-gray-600 rounded transition-opacity"
|
||||
aria-label="Delete session"
|
||||
// 会话列表项 - 使用 forwardRef 支持 AnimatePresence 的 popLayout 模式
|
||||
const SessionItem = forwardRef<HTMLDivElement, { session: Session }>(
|
||||
({ session }, ref) => (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
layout
|
||||
variants={fadeInUp}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={smoothTransition}
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 p-3 rounded-lg cursor-pointer group',
|
||||
'hover:bg-gray-700 transition-colors',
|
||||
'active:bg-gray-600',
|
||||
currentSessionId === session.id && 'bg-gray-700'
|
||||
)}
|
||||
>
|
||||
<Trash2 size={14} className="text-gray-400" />
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
<MessageSquare size={18} className="text-gray-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate">
|
||||
{session.name || `Chat ${session.id.slice(0, 8)}`}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{session.messageCount} messages</div>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={(e) => handleDelete(session.id, e)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-gray-600 rounded transition-opacity"
|
||||
aria-label="Delete session"
|
||||
>
|
||||
<Trash2 size={14} className="text-gray-400" />
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)
|
||||
);
|
||||
|
||||
// 加载状态
|
||||
|
||||
@@ -118,11 +118,11 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
||||
|
||||
case 'done':
|
||||
setState((prev) => {
|
||||
const newMessage: Message = message.payload || {
|
||||
id: Date.now().toString(),
|
||||
const newMessage: Message = {
|
||||
id: message.payload?.id || `assistant-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
role: 'assistant',
|
||||
content: prev.streamingContent,
|
||||
timestamp: new Date().toISOString(),
|
||||
content: message.payload?.content || prev.streamingContent,
|
||||
timestamp: message.payload?.timestamp || new Date().toISOString(),
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
@@ -134,11 +134,19 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
||||
break;
|
||||
|
||||
case 'message_received':
|
||||
// 用户消息已确认
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
messages: [...prev.messages, message.payload],
|
||||
}));
|
||||
// 用户消息已确认 - 构建完整的消息对象
|
||||
setState((prev) => {
|
||||
const userMessage: Message = {
|
||||
id: `user-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
role: 'user',
|
||||
content: message.payload?.content || '',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
messages: [...prev.messages, userMessage],
|
||||
};
|
||||
});
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
|
||||
Reference in New Issue
Block a user