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:
2025-12-15 10:24:45 +08:00
parent 842cf1a3e8
commit 9e55237dae
6 changed files with 139 additions and 97 deletions
+4 -3
View File
@@ -17,15 +17,16 @@
}, },
"dependencies": { "dependencies": {
"@ai-assistant/server": "workspace:*", "@ai-assistant/server": "workspace:*",
"commander": "^12.1.0", "blessed": "^0.1.81",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"ora": "^8.0.1", "commander": "^12.1.0",
"inquirer": "^9.2.12", "inquirer": "^9.2.12",
"blessed": "^0.1.81" "ora": "^8.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/blessed": "^0.1.25", "@types/blessed": "^0.1.25",
"@types/bun": "^1.1.0", "@types/bun": "^1.1.0",
"@types/inquirer": "^9.0.9",
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
"typescript": "^5.3.3" "typescript": "^5.3.3"
}, },
+10 -1
View File
@@ -134,8 +134,17 @@ sessionsRouter.get('/:id/messages', async (c) => {
}); });
} }
// 为消息添加 IDCore 的 ModelMessage 格式没有 id 字段)
const messagesWithId = sessionData.messages.map(
(msg: { role: string; content: unknown }, index: number) => ({
...msg,
id: `${msg.role}-${id}-${index}`,
timestamp: new Date().toISOString(),
})
);
return c.json({ return c.json({
success: true, success: true,
data: sessionData.messages, data: messagesWithId,
}); });
}); });
+53 -50
View File
@@ -4,7 +4,7 @@
import { User, Bot, Copy, Check } from 'lucide-react'; import { User, Bot, Copy, Check } from 'lucide-react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useState } from 'react'; import { useState, forwardRef } from 'react';
import { cn } from '../utils/cn'; import { cn } from '../utils/cn';
import { fadeInUp, smoothTransition } from '../utils/animations'; import { fadeInUp, smoothTransition } from '../utils/animations';
import { Markdown } from './Markdown'; import { Markdown } from './Markdown';
@@ -14,62 +14,65 @@ interface ChatMessageProps {
message: Message; message: Message;
} }
export function ChatMessage({ message }: ChatMessageProps) { export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
const isUser = message.role === 'user'; ({ message }, ref) => {
const [copied, setCopied] = useState(false); const isUser = message.role === 'user';
const [copied, setCopied] = useState(false);
const handleCopy = async () => { const handleCopy = async () => {
await navigator.clipboard.writeText(message.content); await navigator.clipboard.writeText(message.content);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
}; };
return ( return (
<motion.div <motion.div
variants={fadeInUp} ref={ref}
initial="initial" variants={fadeInUp}
animate="animate" initial="initial"
exit="exit" animate="animate"
transition={smoothTransition} exit="exit"
className={cn( transition={smoothTransition}
'group flex gap-4 p-4 rounded-lg',
isUser ? 'bg-gray-800' : 'bg-gray-800/50'
)}
>
<div
className={cn( className={cn(
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0', 'group flex gap-4 p-4 rounded-lg',
isUser ? 'bg-primary-600' : 'bg-green-600' isUser ? 'bg-gray-800' : 'bg-gray-800/50'
)} )}
> >
{isUser ? <User size={18} /> : <Bot size={18} />} <div
</div> className={cn(
<div className="flex-1 min-w-0 overflow-hidden"> 'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0',
<div className="flex items-center justify-between mb-1"> isUser ? 'bg-primary-600' : 'bg-green-600'
<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} />
)} )}
>
{isUser ? <User size={18} /> : <Bot size={18} />}
</div> </div>
</div> <div className="flex-1 min-w-0 overflow-hidden">
</motion.div> <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 { interface StreamingMessageProps {
content: string; content: string;
+37 -34
View File
@@ -4,7 +4,7 @@
* 支持响应式:responsive=true 时桌面端固定显示,移动端抽屉式菜单 * 支持响应式:responsive=true 时桌面端固定显示,移动端抽屉式菜单
*/ */
import { useState, useEffect } from 'react'; import { useState, useEffect, forwardRef } from 'react';
import { Plus, MessageSquare, Trash2, Menu, X, MessageCircle } from 'lucide-react'; import { Plus, MessageSquare, Trash2, Menu, X, MessageCircle } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -127,40 +127,43 @@ export function Sidebar({
</motion.div> </motion.div>
); );
// 会话列表项 // 会话列表项 - 使用 forwardRef 支持 AnimatePresence 的 popLayout 模式
const SessionItem = ({ session }: { session: Session }) => ( const SessionItem = forwardRef<HTMLDivElement, { session: Session }>(
<motion.div ({ session }, ref) => (
layout <motion.div
variants={fadeInUp} ref={ref}
initial="initial" layout
animate="animate" variants={fadeInUp}
exit="exit" initial="initial"
transition={smoothTransition} animate="animate"
onClick={() => handleSelectSession(session.id)} exit="exit"
className={cn( transition={smoothTransition}
'flex items-center gap-2 p-3 rounded-lg cursor-pointer group', onClick={() => handleSelectSession(session.id)}
'hover:bg-gray-700 transition-colors', className={cn(
'active:bg-gray-600', 'flex items-center gap-2 p-3 rounded-lg cursor-pointer group',
currentSessionId === session.id && 'bg-gray-700' '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"
> >
<Trash2 size={14} className="text-gray-400" /> <MessageSquare size={18} className="text-gray-400 flex-shrink-0" />
</motion.button> <div className="flex-1 min-w-0">
</motion.div> <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>
)
); );
// 加载状态 // 加载状态
+17 -9
View File
@@ -118,11 +118,11 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
case 'done': case 'done':
setState((prev) => { setState((prev) => {
const newMessage: Message = message.payload || { const newMessage: Message = {
id: Date.now().toString(), id: message.payload?.id || `assistant-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
role: 'assistant', role: 'assistant',
content: prev.streamingContent, content: message.payload?.content || prev.streamingContent,
timestamp: new Date().toISOString(), timestamp: message.payload?.timestamp || new Date().toISOString(),
}; };
return { return {
...prev, ...prev,
@@ -134,11 +134,19 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
break; break;
case 'message_received': case 'message_received':
// 用户消息已确认 // 用户消息已确认 - 构建完整的消息对象
setState((prev) => ({ setState((prev) => {
...prev, const userMessage: Message = {
messages: [...prev.messages, message.payload], 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; break;
case 'error': case 'error':
+18
View File
@@ -39,6 +39,9 @@ importers:
'@types/bun': '@types/bun':
specifier: ^1.1.0 specifier: ^1.1.0
version: 1.3.4 version: 1.3.4
'@types/inquirer':
specifier: ^9.0.9
version: 9.0.9
'@types/node': '@types/node':
specifier: ^20.11.0 specifier: ^20.11.0
version: 20.19.26 version: 20.19.26
@@ -2155,6 +2158,9 @@ packages:
'@types/hast@3.0.4': '@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
'@types/inquirer@9.0.9':
resolution: {integrity: sha512-/mWx5136gts2Z2e5izdoRCo46lPp5TMs9R15GTSsgg/XnZyxDWVqoVU3R9lWnccKpqwsJLvRoxbCjoJtZB7DSw==}
'@types/js-yaml@4.0.9': '@types/js-yaml@4.0.9':
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
@@ -2190,6 +2196,9 @@ packages:
'@types/resolve@1.20.2': '@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
'@types/through@0.0.33':
resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==}
'@types/trusted-types@2.0.7': '@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@@ -6781,6 +6790,11 @@ snapshots:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
'@types/inquirer@9.0.9':
dependencies:
'@types/through': 0.0.33
rxjs: 7.8.2
'@types/js-yaml@4.0.9': {} '@types/js-yaml@4.0.9': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
@@ -6816,6 +6830,10 @@ snapshots:
'@types/resolve@1.20.2': {} '@types/resolve@1.20.2': {}
'@types/through@0.0.33':
dependencies:
'@types/node': 22.19.2
'@types/trusted-types@2.0.7': {} '@types/trusted-types@2.0.7': {}
'@types/unist@2.0.11': {} '@types/unist@2.0.11': {}