diff --git a/packages/cli/package.json b/packages/cli/package.json index bf608e0..8c4d19d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -17,15 +17,16 @@ }, "dependencies": { "@ai-assistant/server": "workspace:*", - "commander": "^12.1.0", + "blessed": "^0.1.81", "chalk": "^5.3.0", - "ora": "^8.0.1", + "commander": "^12.1.0", "inquirer": "^9.2.12", - "blessed": "^0.1.81" + "ora": "^8.0.1" }, "devDependencies": { "@types/blessed": "^0.1.25", "@types/bun": "^1.1.0", + "@types/inquirer": "^9.0.9", "@types/node": "^20.11.0", "typescript": "^5.3.3" }, diff --git a/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts index f86ee03..37ffda4 100644 --- a/packages/server/src/routes/sessions.ts +++ b/packages/server/src/routes/sessions.ts @@ -134,8 +134,17 @@ sessionsRouter.get('/:id/messages', async (c) => { }); } + // 为消息添加 ID(Core 的 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({ success: true, - data: sessionData.messages, + data: messagesWithId, }); }); diff --git a/packages/ui/src/components/ChatMessage.tsx b/packages/ui/src/components/ChatMessage.tsx index 62ac4cc..dbba720 100644 --- a/packages/ui/src/components/ChatMessage.tsx +++ b/packages/ui/src/components/ChatMessage.tsx @@ -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( + ({ 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 ( - -
- {isUser ? : } -
-
-
- - {isUser ? 'You' : 'AI Assistant'} - - -
-
- {isUser ? ( - // 用户消息:保持原样显示 -
{message.content}
- ) : ( - // AI 消息:Markdown 渲染 - +
+ {isUser ? : }
-
- - ); -} +
+
+ + {isUser ? 'You' : 'AI Assistant'} + + +
+
+ {isUser ? ( + // 用户消息:保持原样显示 +
{message.content}
+ ) : ( + // AI 消息:Markdown 渲染 + + )} +
+
+ + ); + } +); interface StreamingMessageProps { content: string; diff --git a/packages/ui/src/components/Sidebar.tsx b/packages/ui/src/components/Sidebar.tsx index 9700889..19bdcaf 100644 --- a/packages/ui/src/components/Sidebar.tsx +++ b/packages/ui/src/components/Sidebar.tsx @@ -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({ ); - // 会话列表项 - const SessionItem = ({ session }: { session: Session }) => ( - 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' - )} - > - -
-
- {session.name || `Chat ${session.id.slice(0, 8)}`} -
-
{session.messageCount} messages
-
- 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( + ({ session }, ref) => ( + 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' + )} > - - -
+ +
+
+ {session.name || `Chat ${session.id.slice(0, 8)}`} +
+
{session.messageCount} messages
+
+ handleDelete(session.id, e)} + className="opacity-0 group-hover:opacity-100 p-1 hover:bg-gray-600 rounded transition-opacity" + aria-label="Delete session" + > + + + + ) ); // 加载状态 diff --git a/packages/ui/src/hooks/useChat.ts b/packages/ui/src/hooks/useChat.ts index 6d3db58..7386b0b 100644 --- a/packages/ui/src/hooks/useChat.ts +++ b/packages/ui/src/hooks/useChat.ts @@ -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': diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62eb28a..d7e9172 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: '@types/bun': specifier: ^1.1.0 version: 1.3.4 + '@types/inquirer': + specifier: ^9.0.9 + version: 9.0.9 '@types/node': specifier: ^20.11.0 version: 20.19.26 @@ -2155,6 +2158,9 @@ packages: '@types/hast@3.0.4': 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': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} @@ -2190,6 +2196,9 @@ packages: '@types/resolve@1.20.2': 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': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -6781,6 +6790,11 @@ snapshots: dependencies: '@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/json-schema@7.0.15': {} @@ -6816,6 +6830,10 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/through@0.0.33': + dependencies: + '@types/node': 22.19.2 + '@types/trusted-types@2.0.7': {} '@types/unist@2.0.11': {}