当前位置: 首页 > web >正文

Next-AI聊天应用-复用chat组件

文章目录

    • @[TOC](文章目录)
  • 前言

前言

对以下文件进行调整,以实现聊天历史记录的加载和继续聊天功能:

  1. src/app/(protected)/chat/page.tsx: 修改为新建聊天的入口,并准备提取核心聊天逻辑到可复用组件。
  2. src/app/api/get-chat/route.ts: 调整 API,使其能够根据 conversationId 返回特定会话的所有聊天记录。
  3. src/components/AppSidebar.tsx: 确保聊天历史列表项的链接指向正确的动态路由,即 /chat/[conversationId]
  4. 创建动态路由页面和可复用组件:
    • 创建 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>)
}

总结:

  1. get-chat API 现在更灵活,可以根据有无 conversationId 返回不同数据。
  2. AppSidebar 正确链接到动态路由。
  3. 核心聊天逻辑被提取到 ChatPanel 组件。
  4. /chat/page.tsx 用于新聊天,调用 ChatPanel 时不传 conversationId
  5. /chat/[conversationId]/page.tsx 用于历史聊天,从 URL 获取 conversationId 并传递给 ChatPanelChatPanel 内部的 useEffect 会根据 initialConversationId 拉取历史消息。

请确保所有路径和文件名都正确。这些更改应该能满足你的需求。在 ChatPanel.tsx 中,消息的 id 生成和更新逻辑可能需要根据你的具体情况微调,特别是从数据库加载历史消息时如何处理 id

注意: extractJsonObjects 函数在 ChatPanel.tsx 中有一个小修正,以确保 startPos 在访问 text.substring 时是有效的。同时,在 ChatPanel 中,当 initialConversationIdnullundefined 时,会清空消息并重置 currentConversationId,为新会话做准备。

http://www.xdnf.cn/news/12285.html

相关文章:

  • 数据炼金术:电商突围的智能决策革命
  • [闭源saas选项]Pinecone:为向量数据库而生的实时语义搜索引擎
  • OMS主动运维服务:赋能中小企业运维价值升级
  • Java类加载过程
  • 使用子树合并策略更新git项目的部分目录
  • ignore文件不生效的问题
  • 初识硬编码(x86指令描述)
  • 代码随想录算法训练营第九天| 151.翻转字符串里的单词、55.右旋转字符串 、字符串总结
  • CLIP多模态大模型的优势及其在边缘计算中的应用
  • 实时云渲染解决UE像素流送无法进行二次开发的问题
  • spring注解之配置注解
  • 《图解技术体系》How Redis Architecture Evolves?
  • 【科研绘图系列】R语言绘制和弦图(Chord diagram plot)
  • 大模型数据流处理实战:Vue+NDJSON的Markdown安全渲染架构
  • 高防服务器能够抵御哪些网络攻击呢?
  • 宠物空气净化器哪个好用?2025宠物空气净化器测评:352、希喂、有哈
  • 智慧园区数字孪生全链交付方案:降本增效30%,多案例实践驱动全周期交付
  • 基于正点原子阿波罗F429开发板的LWIP应用(5)——TFTP在线升级功能
  • Spring之事务管理方式
  • Go中的协程并发和并发panic处理
  • GitHub 趋势日报 (2025年06月04日)
  • Linux --环境变量,虚拟地址空间
  • 强化学习在LLM中应用:RLHF、DPO
  • 网络通信核心概念全解析:从IP地址到TCP/UDP实战
  • 面试题:Java多线程并发
  • JAVA之 Lambda
  • chrome使用手机调试触屏web
  • Nginx学习笔记
  • 【Go语言基础【2】】数据类型之基础数据类型:数字、字符、布尔、枚举、自定义
  • Unity3D中Newtonsoft.Json序列化优化策略