Next-AI聊天应用-复用chat组件
文章目录
- @[TOC](文章目录)
- 前言
文章目录
- @[TOC](文章目录)
- 前言
前言
对以下文件进行调整,以实现聊天历史记录的加载和继续聊天功能:
src/app/(protected)/chat/page.tsx
: 修改为新建聊天的入口,并准备提取核心聊天逻辑到可复用组件。src/app/api/get-chat/route.ts
: 调整 API,使其能够根据conversationId
返回特定会话的所有聊天记录。src/components/AppSidebar.tsx
: 确保聊天历史列表项的链接指向正确的动态路由,即/chat/[conversationId]
。- 创建动态路由页面和可复用组件:
- 创建
src/app/(protected)/chat/[conversationId]/page.tsx
作为动态路由页面,用于显示特定会话的聊天记录。 - 创建
src/components/ChatPanel.tsx
(或类似名称) 作为可复用的聊天面板组件,包含主要的聊天交互逻辑。
- 创建
具体步骤和代码修改如下:
第一步:调整 src/app/api/get-chat/route.ts
这个 API 需要能够根据 conversationId
(如果提供) 来获取特定对话的所有消息,或者根据 userId
(如果只提供 userId
) 来获取用户的聊天历史列表(每个对话的第一条消息)。
// /next-mobile/src/app/api/get-chat/route.ts
import { NextResponse } from 'next/server'
import prisma from '@/lib/prisma'export async function GET(request: Request) {try {const { searchParams } = new URL(request.url)const userId = searchParams.get('userId')const conversationId = searchParams.get('conversationId')if (!userId && !conversationId) {return NextResponse.json({ error: '缺少 userId 或 conversationId 参数' }, { status: 400 })}if (conversationId) {// 如果提供了 conversationId,则获取该会话的所有消息const messages = await prisma.openRouterChat.findMany({where: {conversationId: conversationId,// 如果需要,也可以在这里加入 userId 进行双重验证...(userId && { userId }), },orderBy: { createdAt: 'asc' }, // 按时间升序排列消息})return NextResponse.json(messages, { status: 200 })} else if (userId) {// 如果只提供了 userId,则获取用户的聊天历史列表 (每个 conversationId 的第一条消息)const grouped = await prisma.openRouterChat.groupBy({by: ['conversationId'],where: { userId },_min: { createdAt: true, id: true }, // 获取每个会话中最早创建的消息的ID})const chatHistories = await prisma.openRouterChat.findMany({where: {id: { in: grouped.map(g => g._min.id).filter(Boolean) as string[] } // 类型断言为 string[]},orderBy: { createdAt: 'desc' }, // 聊天历史列表按最新会话排序select: {id: true,content: true,conversationId: true,createdAt: true, // 确保 createdAt 被选中,以便 AppSidebar 可以使用// 根据需要选择其他字段},})return NextResponse.json(chatHistories, { status: 200 })}// Fallback, 理论上不会执行到这里,因为上面已经处理了所有情况return NextResponse.json({ error: '无效的请求参数' }, { status: 400 });} catch (error) {console.error('获取聊天记录失败:', error)return NextResponse.json({ error: '服务器内部错误' }, { status: 500 })}
}
第二步:修改 src/components/AppSidebar.tsx
确保 chatItem.url
正确地使用了 conversationId
。
//next-mobile/src/components/AppSidebar.tsx
// ... existing code ...
export function AppSidebar() {const [chatHistoryItems, setChatHistoryItems] = useState<Array<{title: string, url: string, icon: any}>>([]) // 明确类型
// ... existing code ...// 使用 Prisma 获取 OpenRouterChat 数据// 注意:这里的API调用现在只获取历史列表,不再需要userId,除非你的API设计如此const response = await fetch(`/api/get-chat?userId=${user?.id}`, { // 确保API能处理仅userId的情况返回历史列表
// ... existing code ...const mappedChats = chats.map((chat: any) => { // 为 chat 添加类型// 取内容的前20个字符作为标题,并去除换行符const title = chat.content.substring(0, 20).replace(/\n/g, " ").trim()return {title: title || `Chat ${chat.conversationId.substring(0, 8)}`, // 使用 conversationId 生成标题url: `/chat/${chat.conversationId}`, // 确保这里是 conversationIdicon: Inbox, }})
// ... existing code ...<SidebarMenuItem key={chatItem.url}>{/* 使用 url 作为 key,确保唯一性 */}<SidebarMenuButton asChild><Link href={chatItem.url}>{chatItem.title}</Link></SidebarMenuButton></SidebarMenuItem>
// ... existing code ...
}
第三步:创建可复用的聊天面板组件 src/components/ChatPanel.tsx
将 中的核心聊天逻辑提取出来。
// /next-mobile/src/components/ChatPanel.tsx
'use client'import { useState, useRef, useEffect, FormEvent } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { ScrollArea } from '@/components/ui/scroll-area'
import ChatMarkdown from '@/components/ChatMarkdown'
import { Navbar } from '@/components/Navbar' // Navbar 应该在页面级别,而不是面板级别,除非特定设计
import { User } from '@supabase/supabase-js'
import { v4 as uuidv4 } from 'uuid'// 定义消息类型
type Message = {role: 'user' | 'assistant'content: stringid?: string
}interface ChatPanelProps {initialConversationId?: string | nullcurrentUser: User | null
}export default function ChatPanel({ initialConversationId, currentUser }: ChatPanelProps) {const [messages, setMessages] = useState<Message[]>([])const [input, setInput] = useState('')const [isLoading, setIsLoading] = useState(false)const [currentConversationId, setCurrentConversationId] = useState<string | null>(initialConversationId || null)const messagesEndRef = useRef<HTMLDivElement>(null)// 当 initialConversationId 变化时 (例如从 URL 参数加载),或者组件首次加载时获取历史消息useEffect(() => {if (initialConversationId) {setCurrentConversationId(initialConversationId);const fetchMessages = async () => {setIsLoading(true);try {const response = await fetch(`/api/get-chat?conversationId=${initialConversationId}`);if (!response.ok) {throw new Error('Failed to fetch chat history');}const historyMessages = await response.json();setMessages(historyMessages.map((msg: any) => ({ // 确保类型正确id: msg.id || uuidv4(), // 如果数据库记录没有id,则生成一个role: msg.role,content: msg.content,})));} catch (error) {console.error(error);// 可以设置错误状态或提示用户} finally {setIsLoading(false);}};fetchMessages();} else {// 如果没有 initialConversationId,则清空消息,准备新会话setMessages([]);setCurrentConversationId(null); // 确保新会话开始时 currentConversationId 为 null}}, [initialConversationId]);const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {setInput(e.target.value)}const handleSubmit = async (e: FormEvent) => {e.preventDefault()if (!input.trim() || isLoading || !currentUser) {if (!currentUser) console.error('用户未登录,无法发送消息。')return}setIsLoading(true)let convId = currentConversationIdif (!convId) { // 如果是新会话 (currentConversationId 为 null)convId = uuidv4()setCurrentConversationId(convId) // 设置新的 conversationId}const userMessage: Message = { role: 'user', content: input, id: uuidv4() }setMessages(prev => [...prev, userMessage])setInput('')try {const response = await fetch('/api/chat', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({messages: [...messages, userMessage].map(({ role, content }) => ({ role, content })),// 如果API需要,也可以传递 conversationId// conversationId: convId }),})if (!response.ok) throw new Error(`请求失败: ${response.status}`)const assistantMessage: Message = { role: 'assistant', content: '', id: uuidv4() }setMessages(prev => [...prev, assistantMessage])const reader = response.body?.getReader()const decoder = new TextDecoder()if (!reader) throw new Error('无法获取响应流')let buffer = ''let fullAssistantContent = ''while (true) {const { done, value } = await reader.read()if (done) breakconst chunk = decoder.decode(value)buffer += chunkconst jsonObjects = extractJsonObjects(buffer)buffer = jsonObjects.remainderfor (const jsonStr of jsonObjects.objects) {try {const jsonData = JSON.parse(jsonStr)if (jsonData.choices?.[0]?.delta?.content) {let content = jsonData.choices[0].delta.contentif (typeof content === 'object' && content !== null) {content = '```json\n' + JSON.stringify(content, null, 2) + '\n```'}assistantMessage.content += contentfullAssistantContent += contentsetMessages(prev => prev.map(m => m.id === assistantMessage.id ? { ...assistantMessage } : m))}} catch (jsonError) {console.error('JSON 解析错误:', jsonError, jsonStr)}}}if (fullAssistantContent && currentUser && convId) {// 保存用户消息await fetch('/api/save-chat', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({userId: currentUser.id,role: 'user',content: userMessage.content,conversationId: convId,}),});// 保存助手消息await fetch('/api/save-chat', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({userId: currentUser.id,role: 'assistant',content: fullAssistantContent,conversationId: convId,}),});}} catch (error) {console.error('聊天请求错误:', error)setMessages(prev => [...prev,{ role: 'assistant', content: '抱歉,处理您的请求时出错了。请稍后再试。', id: uuidv4() },])} finally {setIsLoading(false)}}function extractJsonObjects(text: string): { objects: string[], remainder: string } {const objects: string[] = []let currentPos = 0let startPos = 0let openBraces = 0let inString = falselet escapeNext = falsewhile (currentPos < text.length) {const char = text[currentPos]if (escapeNext) {escapeNext = false} else if (char === '\\' && inString) {escapeNext = true} else if (char === '"') {inString = !inString} else if (!inString) {if (char === '{') {if (openBraces === 0) {startPos = currentPos}openBraces++} else if (char === '}') {openBraces--if (openBraces === 0 && startPos <= currentPos) { // 确保 startPos 有效objects.push(text.substring(startPos, currentPos + 1))}}}currentPos++}const lastObjectEnd = objects.length > 0 ? text.indexOf(objects[objects.length - 1]) + objects[objects.length - 1].length : 0;return {objects,remainder: text.substring(lastObjectEnd)}}useEffect(() => {messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })}, [messages])return (<Card className="h-full flex flex-col"><CardHeader><CardTitle>AI 聊天助手 {currentConversationId ? `(会话: ${currentConversationId.substring(0,8)})` : "(新会话)"}</CardTitle></CardHeader><CardContent className="flex-1 overflow-hidden"><ScrollArea className="h-full pr-4"><div className="space-y-4">{messages.length === 0 && !isLoading && (<div className="text-center text-muted-foreground py-8">{initialConversationId ? "加载历史消息中..." : "开始一个新的对话吧!"}</div>)}{isLoading && messages.length === 0 && (<div className="text-center text-muted-foreground py-8">加载中...</div>)}{messages.map((message) => (<divkey={message.id}className={`p-4 rounded-lg ${message.role === 'user'? 'bg-primary/10 ml-auto max-w-[80%]': 'bg-muted max-w-[80%]'}`}>{message.role === 'user' ? (<p className="text-sm whitespace-pre-wrap">{message.content}</p>) : (<div className="prose prose-sm dark:prose-invert max-w-none"><ChatMarkdown content={message.content} /></div>)}</div>))}<div ref={messagesEndRef} /></div></ScrollArea></CardContent><CardFooter><form onSubmit={handleSubmit} className="flex w-full gap-2"><Inputvalue={input}onChange={handleInputChange}placeholder="输入您的问题..."disabled={isLoading || !currentUser} // 如果用户未登录也禁用className="flex-1"/><Button type="submit" disabled={isLoading || !currentUser}>{isLoading ? '发送中...' : '发送'}</Button></form></CardFooter></Card>)
}
第四步:修改 src/app/(protected)/chat/page.tsx
(新建聊天页面)
这个页面现在将使用 ChatPanel
组件,并且不传递 initialConversationId
。
// /next-mobile/src/app/(protected)/chat/page.tsx
'use client'import { useState, useEffect } from 'react'
import ChatPanel from '@/components/ChatPanel' // 引入 ChatPanel
import { Navbar } from '@/components/Navbar'
import { createClient } from '@/lib/supabase/client'
import { User } from '@supabase/supabase-js'export default function NewChatPage() {const [currentUser, setCurrentUser] = useState<User | null>(null)const supabase = createClient()useEffect(() => {const fetchUser = async () => {const { data: { user } } = await supabase.auth.getUser()setCurrentUser(user)}fetchUser()const { data: authListener } = supabase.auth.onAuthStateChange((event, session) => {setCurrentUser(session?.user ?? null)})return () => {authListener.subscription.unsubscribe()}}, [supabase])return (<div className="flex h-screen w-full"><div className="flex flex-col flex-1"><Navbar /><div className="container mx-auto max-w-4xl flex-1 p-2 py-4">{/* currentUser 加载完成后再渲染 ChatPanel */}{currentUser !== undefined && (<ChatPanel currentUser={currentUser} />)}</div></div></div>)
}
第五步:创建动态路由页面 src/app/(protected)/chat/[conversationId]/page.tsx
这个页面用于加载特定 conversationId
的聊天记录。
// /next-mobile/src/app/(protected)/chat/[conversationId]/page.tsx
'use client'import { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import ChatPanel from '@/components/ChatPanel' // 引入 ChatPanel
import { Navbar } from '@/components/Navbar'
import { createClient } from '@/lib/supabase/client'
import { User } from '@supabase/supabase-js'export default function ConversationChatPage() {const params = useParams()const conversationId = params.conversationId as string | undefined // conversationId 可能为 undefinedconst [currentUser, setCurrentUser] = useState<User | null>(null)const supabase = createClient()useEffect(() => {const fetchUser = async () => {const { data: { user } } = await supabase.auth.getUser()setCurrentUser(user)}fetchUser()const { data: authListener } = supabase.auth.onAuthStateChange((event, session) => {setCurrentUser(session?.user ?? null)})return () => {authListener.subscription.unsubscribe()}}, [supabase])return (<div className="flex h-screen w-full"><div className="flex flex-col flex-1"><Navbar /><div className="container mx-auto max-w-4xl flex-1 p-2 py-4">{/* currentUser 和 conversationId 都存在时才渲染 ChatPanel */}{currentUser !== undefined && conversationId && (<ChatPanel currentUser={currentUser} initialConversationId={conversationId} />)}{/* 可以添加 conversationId 不存在时的处理逻辑 */}{!conversationId && <p>无效的会话 ID。</p>}</div></div></div>)
}
总结:
get-chat
API 现在更灵活,可以根据有无conversationId
返回不同数据。AppSidebar
正确链接到动态路由。- 核心聊天逻辑被提取到
ChatPanel
组件。 /chat/page.tsx
用于新聊天,调用ChatPanel
时不传conversationId
。/chat/[conversationId]/page.tsx
用于历史聊天,从 URL 获取conversationId
并传递给ChatPanel
,ChatPanel
内部的useEffect
会根据initialConversationId
拉取历史消息。
请确保所有路径和文件名都正确。这些更改应该能满足你的需求。在 ChatPanel.tsx
中,消息的 id
生成和更新逻辑可能需要根据你的具体情况微调,特别是从数据库加载历史消息时如何处理 id
。
注意: extractJsonObjects
函数在 ChatPanel.tsx
中有一个小修正,以确保 startPos
在访问 text.substring
时是有效的。同时,在 ChatPanel
中,当 initialConversationId
为 null
或 undefined
时,会清空消息并重置 currentConversationId
,为新会话做准备。