这篇笔记, 记录调用DeepSeekAPI, 实现DeepSeek网页版。 先看下效果:这个截图的URL,不是官网DeepSeek, 是我们在vercel上自己部署的, 是我们自己写的代码。
适配移动端
GIF展示开启新对话,聊天,可以看到数据是流式获取的
好奇的小伙伴可以自己体验下,(部署用Vercel,境外服务器可能需要VPN)在线体验链接
这个代码是基于Build Full Stack DeepSeek Clone Using Next JS With DeepSeek API | AI Project In Next Js的视频(Youtube)教程,但是博主的代码需要付费才能查看,我跟着视频一点点敲出来的,在原教程的基础上增加了TypeScript, 流式获取DeepSeek响应数据(SSE),小的交互优化)
代码Github,可以去取项目用到的资源,比如DeepSeek logo, 一些图标, 代码仓库链接
介绍下技术栈:我们使用Next.js、tailwindCSS、 MongoDB、Clerk(用户管理)。我们按照以下流程开始
- 新建项目
- 使用Next.js 新建项目引入项目资源新建一些文件夹 (components、context、config、models等)
- 侧边栏开发消息输入框组件开发
- 使用Clerk完成用户授权有了用户数据后 使用contextProvider 使其全局使用
- 新建ChatLabel组件 便于管理不同对话新建Message组件 渲染多端对话
- 创建项目,完成初始化流程新建Collection, 建立数据库的连接
- 用户信息接口开发对话接口开发 获取用户对话列表 新建对话 删除对话 重命名接口开发获取DeepSeek回复
- 调用AI接口使用Markdown渲染代码格式高亮对话标签逻辑完善
我们开始吧: )
新建项目
我们先用Next.js搭建项目框架,根据Next.js官方文档,在控制台执行下面命令
npx create-next-app@latest
上面命令会启动引导程序,我全部选择默认选项,比如使用TypeScript, 使用ESLint, Tailwind CSS, 使用App Router
app文件夹下面有layout.tsx, page.tsx这时候就可以运行
npm run dev
把项目跑起来了
引入项目资源及一些配置
在前面给出了Github的地址,我们把要用到的图标assets文件夹拿过来,放到src目录下,更新layout.tsx里面的metadata,鼠标渲染浏览器标签显示的内容
export const metadata: Metadata = { title: 'DeepSeek - 探索未至之境', description: 'DeepSeek 是一个探索未知领域的工具,旨在帮助用户发现和理解新知识。',}
在src目录下新建如下文件夹
- components (组件)config (数据库连接设置)context (存储共享的数据)models (数据库模型 Chat User)types (类型文件)
前端页面开发
在page.tsx写如下代码:
- 适配小屏加菜单按钮 消息按钮确立页面结构 对话界面 输入框 免责声明
'use client'import Image from 'next/image'import { assets } from '@/assets/assets'import { useState } from 'react'interface MessageType { role: 'user' | 'assistant' content: string timestamp: number}export default function Home() { const [expand, setExpand] = useState(false); const [messages, setMessages] = useState<MessageType[]>([]); const [isLoading, setIsLoading] = useState(false); return ( <div className="flex h-screen"> {/* 左侧导航栏 */} <div className="flex-1 flex flex-col items-center justify-center px-4 pb-8 bg-[#292a2d] text-white relative" > <div className="md:hidden absolute px-4 top-6 flex items-center justify-between w-full"> <Image onClick={() => (expand ? setExpand(false) : setExpand(true))} className="rotate-180" src={assets.menu_icon} alt="" /> <Image className="opacity-70" src={assets.chat_icon} alt="" /> </div> {messages.length === 0 ? ( <> <div className="flex items-center gap-3"> <Image src={assets.logo_icon} alt="" className="h-16" /> <p className="text-2xl font-medium"> 我是 DeepSeek,很高兴见到你! </p> </div> <p className="text-sm mt-2"> 我可以帮你写代码、读文件、写作各种创意内容,请把你的任务交给我吧~ </p> </> ) : ( <div></div> )} {/* 对话输入框 */} <p className="text-xs absolute bottom-1 text-gray-500"> 内容由 AI 生成,请仔细甄别 </p> </div> </div> )}
启动项目可以看到如下效果 (移动端多了我们加的适配 菜单 对话图标)
写一个侧边栏Sidebar.tsx文件,
import React from 'react'import Image from 'next/image'import { assets } from '@/assets/assets'interface SidebarProps { expand: boolean setExpand: (value: boolean) => void}const createNew = () => {}const Sidebar: React.FC<SidebarProps> = ({ expand, setExpand }) => { return ( <div className={`flex flex-col justify-between bg-[#212327] pt-7 transition-all z-50 max-md:absolute max-md:h-screen ${ expand ? 'p-4 w-64' : 'md:w-20 w-0 max-md:overflow-hidden' }`} > <div> <div className={`flex ${ expand ? 'flex-row gap-10' : 'flex-col items-center gap-8' }`} > <Image className={expand ? 'w-36' : 'w-10'} src={expand ? assets.logo_text : assets.logo_icon} alt="" /> <div onClick={() => (expand ? setExpand(false) : setExpand(true))} className="group relative flex items-center justify-center hover:bg-gray-500/20 transition-all duration-300 h-9 w-9 aspect-square rounded-lg cursor-pointer" > <Image src={assets.menu_icon} alt="菜单图标" className="md:hidden" /> <Image src={expand ? assets.sidebar_close_icon : assets.sidebar_icon} alt="展开/收起边栏" className="hidden md:block cursor-pointer w-7 h-7" /> <div className={`absolute w-max ${ expand ? 'left-1/2 -translate-x-1/2 top-12' : '-top-12 left-0' } opacity-0 group-hover:opacity-100 transition bg-black text-white text-sm px-3 py-2 rounded-lg shadow-lg pointer-events-none`} > {expand ? '收起边栏' : '打开边栏'} <div className={`w-3 h-3 absolute bg-black rotate-45 ${ expand ? 'left-1/2 -top-1.5 -translate-x-1/2' : 'left-4 -bottom-1.5' }`} ></div> </div> </div> </div> <button onClick={createNew} className={`mt-8 flex items-center justify-center cursor-pointer ${ expand ? 'bg-primary hover:opacity-90 rounded-2xl gap-2 p-2.5 w-max' : 'group relative h-9 w-9 mx-auto hover:bg-gray-500/30 rounded-lg' }`} > <Image className={expand ? 'w-6' : 'w-7'} src={expand ? assets.chat_icon : assets.chat_icon_dull} alt="" /> <div className="absolute w-max -top-12 -right-12 opacity-0 group-hover:opacity-100 transition bg-black text-white text-sm px-3 py-2 rounded-lg shadow-lg pointer-events-none" > 开启新对话 <div className="w-3 h-3 absolute bg-black rotate-45 left-4 -bottom-1.5"></div> </div> {expand && <p className="text-white text font-medium">开启新对话</p>} </button> <div className={`mt-8 text-white/25 text-sm ${ expand ? 'block' : 'hidden' }`} > <p className="my-1">最近</p> {/* chatLabel 可以替换为实际的聊天标签数据 */} </div> </div> <div> <div className={`flex items-center cursor-pointer group relative ${ expand ? 'gap-1 text-white/80 text-sm p-2.5 border border-primay rounded-lg hover:bg-white/10 cursor-pointer' : 'h-10 w-10 mx-auto hover:bg-gray-500/30 rounded-lg' }`} > <Image className={expand ? 'w-5' : 'w-6.5 mx-auto'} src={expand ? assets.phone_icon : assets.phone_icon_dull} alt="" /> <div className={`absolute -top-60 pb-8 ${ !expand && '-right-40' } opacity-0 group-hover:opacity-100 hidden group-hover:block transition`} > <div className="relative w-max bg-black text-white text-sm p-3 rounded-lg shadow-lg"> <Image src={assets.qrcode} alt="" className="w-44" /> <p>扫码下载 DeepSeek APP</p> <div className={`w-3 h-3 absolute bg-black rotate-45 ${ expand ? 'right-1/2' : 'left-4' } -bottom-1.5`} ></div> </div> </div> {expand && ( <> <span>下载 App</span> <Image alt="" src={assets.new_icon} /> </> )} </div> <div className={`flex items-center ${ expand ? 'hover:bg-white/10 rounded-lg' : 'justify-center w-full' } gap-3 text-white/60 text-sm p-2 mt-2 cursor-pointer`} > <Image src={assets.profile_icon} alt="" className="w-7" /> {expand && <span>个人信息</span>} </div> </div> </div> )}export default Sidebar
引入到page.tsx
import Sidebar from '@/components/Sidebar'export default function Home() { return ( <div className="flex h-screen"> <Sidebar expand={expand} setExpand={setExpand} /> ...
代码主要分为2部分
- 上部分主要是LOGO 展开收起按钮 聊天消息按钮下部分主要是下载APP按钮 个人信息按钮
消息输入框组件开发
新建一个PromptBox组件,
import React, { useState, useRef, useEffect, KeyboardEvent, FormEvent,} from 'react'import Image from 'next/image'import { assets } from '@/assets/assets'// 定义组件 props 类型interface PromptBoxProps { setIsLoading: (isLoading: boolean) => void isLoading: boolean}const PromptBox: React.FC<PromptBoxProps> = ({ setIsLoading, isLoading }) => { const [prompt, setPrompt] = useState<string>('') const textareaRef = useRef<HTMLTextAreaElement>(null) return ( <form className={`w-full ${ true ? 'max-w-3xl' : 'max-w-2xl' } bg-[#404045] p-4 rounded-3xl mt-4 transition-all`} > <textarea ref={textareaRef} className="outline-none w-full resize-none overflow-y-auto break-words bg-transparent max-h-[336px] text-white" rows={2} placeholder="给 DeepSeek 发送消息" required onChange={e => setPrompt(e.target.value)} value={prompt} /> <div className="flex items-center justify-between text-sm"> <div className="flex items-center gap-2"> <p className="flex items-center gap-2 text-xs border border-gray-300/40 px-2 py-1 rounded-full cursor-pointer hover:bg-gray-500/20 transition" > <Image className="h-5" src={assets.deepthink_icon} alt="" /> 深度思考 (R1) </p> <p className="flex items-center gap-2 text-xs border border-gray-300/40 px-2 py-1 rounded-full cursor-pointer hover:bg-gray-500/20 transition" > <Image className="h-5" src={assets.search_icon} alt="" /> 联网搜索 </p> </div> <div className="flex items-center gap-2"> <Image className="w-4 cursor-pointer" src={assets.pin_icon} alt="" /> <button className={`${prompt ? 'bg-primary' : 'bg-[#71717a]'} rounded-full p-2 cursor-pointer`} > <Image className="w-3.5 aspect-square" src={prompt ? assets.arrow_icon : assets.arrow_icon_dull} alt="" /> </button> </div> </div> </form> )}export default PromptBox
引入到Page.tsx
... <PromptBox isLoading={isLoading} setIsLoading={setIsLoading} /><p className="text-xs absolute bottom-1 text-gray-500"> 内容由 AI 生成,请仔细甄别</p>
到此我们前端页面就告一段落了,可以着手功能开发了
利用Clerk实现用户授权体系
新建项目
可以看到有我们项目用到的Next.js的集成说明
咱们按照说明来
- npm install @clerk/nextjs在.env里增加key更新middleware.ts (在src目录下新增
clerkMiddleware
助手函数用于启用身份验证,并且其中配置受保护的路由)在应用里添加ClerkProvider重新运行项目我们在Layout.tsx里添加ClerkProviderimport { ClerkProvider } from '@clerk/nextjs'...export default function RootLayout({ children,}: Readonly<{ children: React.ReactNode}>) { return ( <ClerkProvider> <html lang="en"> <body className={`${geistSans.variable} ${geistMono.variable} antialiased`} > {children} </body> </html> </ClerkProvider> )}
记得我们Sidebar有一个我的信息按钮,我们想要用户点击这个按钮的时候如果没有登录,就登录,在Sidebar文件引入注册/登录
import { useClerk, UserButton } from '@clerk/nextjs'const createNew = () => {}const Sidebar: React.FC<SidebarProps> = ({ expand, setExpand }) => { const { openSignIn } = useClerk() return ( ... <div onClick={() => openSignIn()} className={`flex items-center ${ expand ? 'hover:bg-white/10 rounded-lg' : 'justify-center w-full' } gap-3 text-white/60 text-sm p-2 mt-2 cursor-pointer`} > <Image src={assets.profile_icon} alt="" className="w-7" /> {expand && <span>个人信息</span>} </div> )
这时候就能弹出Clerk的注册/登录了,说明我们引入成功了
在Context文件夹下新建AppContext.tsx
因为用户信息我们需要在不同组件里共享,所以我们放在AppContext, 新建的AppContext.tsx
'use client'import React, { createContext, useContext, ReactNode } from 'react'import { useUser } from '@clerk/nextjs'type UserType = ReturnType<typeof useUser>['user']interface AppContextType { user: UserType}interface AppContextType { user: UserType}export const AppContext = createContext<AppContextType>({ user: null,})export const useAppContext = () => useContext(AppContext)interface AppContextProviderProps { children: ReactNode}export const AppContextProvider = ({ children }: AppContextProviderProps) => { const { user } = useUser() const value: AppContextType = { user, }dance return <AppContext.Provider value={value}>{children}</AppContext.Provider>}
然后我们在Layout.tsx, 引入AppContext.Provider, 这样我们就可以在整个应用里使用了
import { ClerkProvider } from '@clerk/nextjs'...export default function RootLayout({ children,}: Readonly<{ children: React.ReactNode}>) { return ( <ClerkProvider> <AppContextProvider> <html lang="en"> ... </html> </AppContextProvider> </ClerkProvider>
改造下Sidebar, 如果有用户信息就显示,点击的时候如果有用户信息就不要登录了, onClick={user ? undefined : () => openSignIn()}
import { useAppContext } from '@/context/AppContext'...const Sidebar: React.FC<SidebarProps> = ({ expand, setExpand }) => { const { openSignIn } = useClerk() const { user } = useAppContext() ... <div onClick={user ? undefined : () => openSignIn()} className={`flex items-center ${ expand ? 'hover:bg-white/10 rounded-lg' : 'justify-center w-full' } gap-3 text-white/60 text-sm p-2 mt-2 cursor-pointer`} > {user ? ( <UserButton /> ) : ( <Image src={assets.profile_icon} alt="" className="w-7" /> )} {expand && <span>个人信息</span>}</div>
点击注册
完善前端页面
我们的页面还欠缺用户对话管理,还没有用户对话组件
新建ChatLabel组件
给聊天对话进行重命名,删除
import React, { Dispatch, SetStateAction } from 'react'import { assets } from '@/assets/assets'import Image from 'next/image'export type OpenMenuState = { open: boolean}// 定义组件 Props 类型interface ChatLabelProps { openMenu: OpenMenuState setOpenMenu?: Dispatch<SetStateAction<OpenMenuState>> // 接收完整状态对象}const ChatLabel: React.FC<ChatLabelProps> = ({ openMenu, setOpenMenu }) => { const renameHandler = async () => {} const deleteHandler = async () => {} const wrapperClick = (e: React.MouseEvent) => { e.stopPropagation() // 阻止事件冒泡,避免触发 selectChat setOpenMenu?.({ open: !openMenu.open }) // 切换菜单状态 } return ( <div className={`flex items-center justify-between p-2 text-white/80 hover:bg-white/10 rounded-lg text-sm group cursor-pointer`} > <p className="group-hover:max-w-5/6 truncate">新聊天</p> <div className="group relative flex items-center justify-center h-6 w-6 aspect-square hover:bg-black/80 rounded-lg" onClick={e => wrapperClick(e)} > <Image src={assets.three_dots} alt="" className={`w-4 ${ openMenu.open ? 'block' : 'hidden' } group-hover:block`} /> <div className={`absolute ${ openMenu.open ? 'block' : 'hidden' } -right-32 top-6 bg-gray-700 rounded-xl w-max p-2`} > <div onClick={renameHandler} className="flex items-center gap-3 hover:bg-white/10 px-3 py-2 rounded-lg" > <Image src={assets.pencil_icon} alt="" className="w-4" /> <p>重命名</p> </div> <div className="flex items-center gap-3 hover:bg-white/10 px-3 py-2 rounded-lg" onClick={deleteHandler} > <Image src={assets.delete_icon} alt="" className="w-4" /> <p>删除</p> </div> </div> </div> </div> )}export default ChatLabel
在Sidebar引入ChatLabel
import ChatLabel from './ChatLabel'type OpenMenuState = { open: boolean}const Sidebar: React.FC<SidebarProps> = ({ expand, setExpand }) => { ... const [openMenu, setOpenMenu] = useState<OpenMenuState>({ open: false, }) return ( ... <div className={`mt-8 text-white/25 text-sm ${ expand ? 'block' : 'hidden' }`} > <p className="my-1">最近</p> <ChatLabel openMenu={openMenu} setOpenMenu={setOpenMenu} /> </div> ... )
鼠标悬浮高亮 出现3个点 点击三个点图标弹出对话框
新建Message组件
- 角色是用户的话 对话悬浮显示复制 编辑按钮角色是AI的话 对话悬浮显示复制 重新生成 喜欢 不喜欢按钮
import React from 'react'import Image from 'next/image'import { assets } from '@/assets/assets'// 定义Message组件的props类型type MessageProps = { role: 'user' | 'assistant' // 限定role为这两种值 content: string // 允许任何React可渲染内容}const Message = ({ role, content }: MessageProps) => { const copyMessage = () => { navigator.clipboard.writeText(content as string) } return ( <div className="flex flex-col items-center w-full max-w-3xl text-sm"> <div className={`flex flex-col w-full mb-8 ${ role === 'user' && 'items-end' }`} > <div className={`group relative flex max-w-2xl py-3 rounded-xl ${role === 'user' ? 'bg-[#414158] px-5' : 'gap-3'}`} > <div className={`opacity-0 group-hover:opacity-100 absolute ${ role === 'user' ? '-left-16 top-2.5' : 'left-9 -bottom-6' } transition-all`} > <div className="flex items-center gap-2 opacity-70"> {role === 'user' ? ( <> <Image onClick={copyMessage} src={assets.copy_icon} alt="" className="w-4 cursor-pointer" /> <Image src={assets.pencil_icon} alt="" className="w-4 cursor-pointer" /> </> ) : ( <> <Image onClick={copyMessage} src={assets.copy_icon} alt="" className="w-4.5 cursor-pointer" /> <Image src={assets.regenerate_icon} alt="" className="w-4 cursor-pointer" /> <Image src={assets.like_icon} alt="" className="w-4 cursor-pointer" /> <Image src={assets.dislike_icon} alt="" className="w-4 cursor-pointer" /> </> )} </div> </div> {role === 'user' ? ( <span className="text-white/90">{content}</span> ) : ( <> <Image src={assets.logo_icon} alt="" className="h-9 w-9 p-1 border-white/15 rounded-full" /> <div className="space-y-4 w-full overflow-scroll">{content}</div> </> )} </div> </div> </div> )}export default Message
在Page.tsx引入Message组件
import Message from '@/components/Message'export default function Home() { const [messages, setMessages] = useState<MessageType[]>([]) {messages.length !== 0 ? ( <> ... </> ) : ( <div> <Message role="user" content="你好 九江" /> </div> )}
因为我们messages为空,为了看到我们的对话框组件效果,我们改为messages.length === 0 显示对话框组件,看到效果后恢复下messages.length逻辑
终于把前端页面逻辑完成了,到了激动人心的调用DeepSeek API,把数据存到数据库里这个环节了
数据库连接
写接口,存数据,渲染数据,调用DeepSeek API, 我们先安装pnpm i axios mongoose openai svix prismjs react-hot-toast react-markdown
- axios (HTTP请求)mongoose (专为 Node.js 和 MongoDB 设计的对象数据建模(ODM)库)openai (调用DeepSeek需要这个)svix (前面安装的Clerk需要 Clerk 通过 Webhook 向你的应用推送身份验证相关事件(如用户注册、登录、资料更新),而 Svix 正是 Clerk 用于处理这些 Webhook 的底层库)prismjs (代码语法高亮库)react-hot-toast (消息通知)react-markdown (渲染markdown)
新建好了,咱们重新跑下项目,去mongoDB网站新建项目,有项目直接新建Collection
新建项目
去新建Cluster
选择免费版本
去连接 设置安全设置 只允许本地IP连接
选择连接方法
Mongoose方式
复制下这里的URL
mongodb+srv://deepseek:<db_password>@deepseek.izxme3j.mongodb.net/?retryWrites=true&w=majority&appName=DeepSeek// mongodb+srv://<用户名>:<密码>@<集群地址>/<数据库名称>
在.env里添加变量
MONGODB_URI=mongodb+srv://deepseek:deepseek@deepseek.izxme3j.mongodb.net/deepseek
允许IP都可以访问
在前面建立的config文件夹下建立db.ts文件,建立数据库连接设置
/** * 确保整个应用生命周期中只创建一个 MongoDB 连接实例,避免重复连接导致的性能问题。 * 在开发环境中,Next.js 的热重载会重新执行代码,导致多次调用 mongoose.connect(),创建大量连接。 * 因此,使用全局变量缓存连接实例。 */import mongoose, { Connection } from 'mongoose'interface Cache { conn: Connection | null promise: Promise<Connection> | null}// Extend the global object typedeclare global { let mongoose: Cache | undefined}// Use global variable or initialize itconst globalWithMongoose = global as typeof globalThis & { mongoose?: Cache }const cached: Cache = globalWithMongoose.mongoose || { conn: null, promise: null,}export default async function connectDB(): Promise<Connection> { if (cached.conn) return cached.conn if (!cached.promise) { cached.promise = mongoose .connect(process.env.MONGODB_URI!) .then(m => m.connection) } try { cached.conn = await cached.promise globalWithMongoose.mongoose = cached return cached.conn } catch (err) { cached.promise = null throw err }}
在Models文件夹下新建User.ts (数据库模型 有哪些字段 类型)
import mongoose from 'mongoose'const UserSchema = new mongoose.Schema( { _id: { type: String, required: true }, name: { type: String, required: true }, email: { type: String, required: true }, image: { type: String, required: false }, }, { timestamps: true })const User = mongoose.models.User || mongoose.model('User', UserSchema)export default User
在src/app 下新建api/clerk/route.ts文件
import { Webhook } from 'svix'import connectDB from '@/config/db'import User from '@/models/User'import { headers } from 'next/headers'import { NextRequest, NextResponse } from 'next/server'interface SvixEvent { data: { id: string email_addresses: { email_address: string }[] first_name: string last_name: string image_url: string } type: 'user.created' | 'user.updated' | 'user.deleted'}export async function POST(req: NextRequest) { // 验证环境变量是否存在 const signingSecret = process.env.SIGNING_SECRET if (!signingSecret) { return NextResponse.json( { error: '没有找到 SIGNING_SECRET 环境变量' }, { status: 500 } ) } const wh = new Webhook(signingSecret) const headerPayload = await headers() // 获取必要的 Svix 头部信息 const svixId = headerPayload.get('svix-id') const svixTimestamp = headerPayload.get('svix-timestamp') const svixSignature = headerPayload.get('svix-signature') if (!svixId || !svixTimestamp || !svixSignature) { return NextResponse.json( { error: '没有找到必要的 Svix 头部信息' }, { status: 400 } ) } const svixHeaders = { 'svix-id': svixId, 'svix-timestamp': svixTimestamp, 'svix-signature': svixSignature, } // 获取请求体并转换为字符串 const payload = await req.json() const body = JSON.stringify(payload) // 验证请求的有效性 const { data, type } = wh.verify(body, svixHeaders) as SvixEvent // 准备用户数据 const userData = { _id: data.id, email: data.email_addresses[0].email_address, name: `${data.first_name} ${data.last_name}`, image: data.image_url, } await connectDB() // 处理不同类型的事件 switch (type) { case 'user.created': await User.create(userData) break case 'user.updated': await User.findByIdAndUpdate(data.id, userData) break case 'user.deleted': await User.findByIdAndDelete(data.id) break default: break } return NextResponse.json({ message: 'Event received', })}
代码里的user.created, user.updated, user.deleted都来自这里
代码里用到的SIGNING_SECRET目前还没有,我们先在.env里添加一个空的
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_cHJvbXB0LXNrdW5rLTY2LmNsZXJrLmFjY291bnRzLmRldiQCLERK_SECRET_KEY=sk_test_RXt9Gwrzhd3jcJ0FGieTeKBsrH8lsKF3YHuNhgrUPkMONGODB_URI=mongodb+srv://deepseek:deepseek@deepseek.izxme3j.mongodb.net/deepseekSIGNING_SECRET=''
去Vercel部署
我们先提交下代码,去Vercel部署,获取一个在线URL;去Vercel新建项目,导入刚才提交的代码仓库
添加环境变量,点击部署
获取到了在线URL
再回到我们的Clerk dashboard.
添加Endpoint, 把我们刚才的URL加上/api/clerk, 勾选user
就有了我们要的signing secret
在Vercel里重新添加下,重新部署
我们删除注册的这个用户,重新注册下,这样新注册的用户应该就在数据库里面了
我们重新注册下
数据库里有数据了
clerk里也能看到触发的这些日志
写对话相关接口
我们先在Models下新建Chat.ts,定义需要哪些字段,校验关系
import mongoose from 'mongoose'const ChatSchema = new mongoose.Schema( { name: { type: String, required: true }, messages: [ { role: { type: String, required: true }, content: { type: String, required: true }, createdAt: { type: Date, default: Date.now }, }, ], userId: { type: String, required: true }, }, { timestamps: true })const Chat = mongoose.models.Chat || mongoose.model('Chat', ChatSchema)export default Chat
然后在src/app/api/文件夹下新建chat/create/route.ts,新增对话接口
import connectDB from '@/config/db'import Chat from '@/models/Chat'import { getAuth } from '@clerk/nextjs/server'import { NextRequest, NextResponse } from 'next/server'export async function POST(req: NextRequest) { try { const { userId } = getAuth(req) if (!userId) { return NextResponse.json({ success: false, message: '用户未授权', }) } const chatData = { userId, messages: [], name: '新聊天', // Default chat name } await connectDB() await Chat.create(chatData) return NextResponse.json({ success: true, message: '聊天创建成功', }) } catch (error: unknown) { let errorMessage = '创建聊天失败' if (error instanceof Error) { errorMessage = error.message } return NextResponse.json( { success: false, error: errorMessage, }, { status: 500 } ) }}
新建chat/get/route.ts,获取对话接口
import connectDB from '@/config/db'import Chat from '@/models/Chat'import { NextRequest, NextResponse } from 'next/server'import { getAuth } from '@clerk/nextjs/server'export async function GET(request: NextRequest) { try { const { userId } = getAuth(request) if (!userId) { return NextResponse.json({ message: '用户未授权', success: false }) } await connectDB() const data = await Chat.find({ userId }) return NextResponse.json({ success: true, data }) // return NextResponse.json(chats, { status: 200 }) } catch (error: unknown) { let errorMessage = '' if (error instanceof Error) { errorMessage = error.message } return NextResponse.json({ message: errorMessage, success: false }) }}
新建chat/rename/route.ts,重命名对话接口
import connectDB from '@/config/db'import Chat from '@/models/Chat'import { getAuth } from '@clerk/nextjs/server'import { NextRequest, NextResponse } from 'next/server'export async function POST(req: NextRequest) { try { const { userId } = getAuth(req) if (!userId) { return NextResponse.json({ success: false, message: '用户未授权', }) } const { chatId, name } = await req.json() await connectDB() await Chat.findOneAndUpdate({ _id: chatId, userId }, { name }) return NextResponse.json({ success: true, message: '聊天重命名成功', }) } catch (error: unknown) { let errorMessage = '重命名聊天失败' if (error instanceof Error) { errorMessage = error.message } return NextResponse.json( { success: false, error: errorMessage, }, { status: 500 } ) }}
新建chat/delete/route.ts,删除对话接口
import connectDB from '@/config/db'import Chat from '@/models/Chat'import { getAuth } from '@clerk/nextjs/server'import { NextRequest, NextResponse } from 'next/server'export async function POST(req: NextRequest) { try { const { userId } = getAuth(req) if (!userId) { return NextResponse.json({ success: false, message: '用户未授权', }) } const { chatId } = await req.json() // Connect to the database and delete the chat await connectDB() await Chat.findOneAndDelete({ _id: chatId, userId }) return NextResponse.json({ success: true, message: '聊天删除成功', }) } catch (error: unknown) { let errorMessage = '删除聊天失败' if (error instanceof Error) { errorMessage = error.message } return NextResponse.json( { success: false, error: errorMessage, }, { status: 500 } ) }}
新建chat/ai/route.ts,获取AI回复接口
根据deepseek 文档,调用deepseek API,需要安装openai, 前面我们已经安装过了
去这个页面取key
在.env添加
...DEEPSEEK_API_KEY=你自己的key
这里的DeepSeek调用, 我加了stream为true,这样返回就是流
export const maxDuration = 60import Chat from '@/models/Chat'import OpenAI from 'openai'import { getAuth } from '@clerk/nextjs/server'import { NextRequest, NextResponse } from 'next/server'import connectDB from '@/config/db'interface ExtendedChatMessage extends OpenAI.ChatCompletionMessage { timestamp: number}// 初始化OpenAI客户端,配置DeepSeek APIconst openai = new OpenAI({ baseURL: 'https://api.deepseek.com', apiKey: process.env.DEEPSEEK_API_KEY || '',})// 处理POST请求export async function POST(req: NextRequest) { try { const { userId } = getAuth(req) // 从请求中获取用户ID // 从请求体中提取chatId和prompt const { chatId, prompt } = await req.json() // 检查用户是否授权 if (!userId) { return NextResponse.json({ success: false, message: '用户未授权', }) } // 连接数据库并查找聊天记录 await connectDB() const data = await Chat.findOne({ userId, _id: chatId }) // 创建用户消息对象 const userPrompt = { role: 'user', content: prompt, timestamp: Date.now(), // 添加时间戳 } data.messages.push(userPrompt) // 将用户消息添加到聊天记录 await data.save() // 设置SSE响应头 const headers = { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', } // 创建可写流来处理SSE const stream = new ReadableStream({ async start(controller) { try { // 调用DeepSeek API获取流式聊天回复 const completion = await openai.chat.completions.create({ messages: [{ role: 'user', content: prompt }], model: 'deepseek-chat', stream: true, store: true, }) let fullContent = '' // 处理流式响应 for await (const chunk of completion) { const content = chunk.choices[0]?.delta?.content || '' if (content) { fullContent += content // 发送SSE事件 controller.enqueue( new TextEncoder().encode( `data: ${JSON.stringify({ content })}\n\n` ) ) } } // 保存AI回复到数据库 const message = { role: 'assistant', content: fullContent, timestamp: Date.now(), } as ExtendedChatMessage data.messages.push(message) await data.save() // 发送最终消息 controller.enqueue( new TextEncoder().encode( `data: ${JSON.stringify({ success: true, data: message })}\n\n` ) ) // 关闭流 controller.close() } catch (error) { // 错误处理 let errorMessage = '聊天回复失败' if (error instanceof Error) { errorMessage = error.message } controller.enqueue( new TextEncoder().encode( `data: ${JSON.stringify({ success: false, error: errorMessage, })}\n\n` ) ) controller.close() } }, }) return new NextResponse(stream, { headers }) } catch (error) { // 初始错误处理 let errorMessage = '聊天回复失败' if (error instanceof Error) { errorMessage = error.message } return NextResponse.json({ success: false, error: errorMessage, }) }}
前端调用接口
因为用户,聊天数据我们多个组件都要用到,我们在AppContext.tsx里去请求
'use client'import React, { createContext, useContext, ReactNode, useState, useEffect, useCallback,} from 'react'import { useUser, useAuth } from '@clerk/nextjs'import axios from 'axios'import toast from 'react-hot-toast'type UserType = ReturnType<typeof useUser>['user']import { MessageType } from '@/types'// 聊天记录类型interface ChatType { _id: string // 对应 MongoDB 的 ObjectId(字符串类型) updatedAt: string // ISO 日期字符串(推荐)或 Date 类型 messages: MessageType[] name?: string}interface AppContextType { user: UserType chats: ChatType[] setChats: React.Dispatch<React.SetStateAction<ChatType[]>> selectedChat: ChatType | null setSelectedChat: React.Dispatch<React.SetStateAction<ChatType | null>> fetchUsersChats: () => Promise<void> createNewChat: () => Promise<void>}export const AppContext = createContext<AppContextType>({ user: null, chats: [], setChats: () => {}, selectedChat: null, setSelectedChat: () => {}, fetchUsersChats: async () => {}, createNewChat: async () => {},})export const useAppContext = () => useContext(AppContext)interface AppContextProviderProps { children: ReactNode}export const AppContextProvider = ({ children }: AppContextProviderProps) => { const { user } = useUser() const { getToken } = useAuth() const [chats, setChats] = useState<ChatType[]>([]) const [selectedChat, setSelectedChat] = useState<ChatType | null>(null) // 🔗 创建新聊天 const createNewChat = useCallback(async () => { try { if (!user) return const token = await getToken() await axios.post( '/api/chat/create', {}, { headers: { Authorization: `Bearer ${token}`, }, } ) toast.success('新聊天创建成功') // 不需要直接调用 fetchUsersChats,这会在调用者自己处理 } catch (error) { const message = error instanceof Error ? error.message : '创建新聊天失败' toast.error(message) } }, [user, getToken]) // 🔗 获取用户聊天列表 const fetchUsersChats = useCallback(async () => { try { if (!user) return const token = await getToken() const { data } = await axios.get('/api/chat/get', { headers: { Authorization: `Bearer ${token}` }, }) if (data.success) { const chatList: ChatType[] = data.data if (chatList.length === 0) { // 没有聊天,自动创建 await createNewChat() // 创建后重新拉取 const retryData = await axios.get('/api/chat/get', { headers: { Authorization: `Bearer ${token}` }, }) const retryChatList: ChatType[] = retryData.data.data || [] setChats(retryChatList) if (retryChatList.length > 0) { const sortedChats = [...retryChatList].sort( (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() ) setSelectedChat(sortedChats[0]) } return } const sortedChats = [...chatList].sort( (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() ) setChats(sortedChats) setSelectedChat(sortedChats[0]) } else { toast.error(data.message || '获取聊天列表失败') } } catch (error) { const message = error instanceof Error ? error.message : '获取聊天列表失败' toast.error(message) } }, [user, getToken, createNewChat]) // 👀 自动拉取聊天列表 useEffect(() => { if (user) { fetchUsersChats() } }, [user, fetchUsersChats]) const value: AppContextType = { user, chats, setChats, selectedChat, setSelectedChat, fetchUsersChats, createNewChat, } return <AppContext.Provider value={value}>{children}</AppContext.Provider>}
现在接口有了,调接口复用方法也写好了。因为layout.ts用了AppContextProvider,这个组件useEffect里有获取用户聊天记录方法,这时候我们刷新页面发现确实自动创建了新对话
因为我们前端代码逻辑写了
- 如果没有聊天,就创建聊天然后重新拉取列表
if (chatList.length === 0) { // 没有聊天,自动创建 await createNewChat() // 创建后重新拉取 const retryData = await axios.get('/api/chat/get', { headers: { Authorization: `Bearer ${token}` }, }) ...
对话也有了,只是messages为空数组,我们要在输入框里发送信息,调用AI接口,获取流并显示,来到PromptBox组件,添加sendPrompt方法
const PromptBox: React.FC<PromptBoxProps> = ({ setIsLoading, isLoading }) => { const [prompt, setPrompt] = useState<string>('') const textareaRef = useRef<HTMLTextAreaElement>(null) const { user, setChats, selectedChat, setSelectedChat } = useAppContext() // 全局状态 const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { // 如果按下的是回车键且没有按住 Shift 键,则发送消息 // Shift + Enter 用于换行 if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() sendPrompt(e) } } const sendPrompt = async ( e: FormEvent<HTMLFormElement> | KeyboardEvent<HTMLTextAreaElement> ) => { const promptCopy = prompt // 缓存当前输入的prompt,用于后续错误恢复 try { // 前置校验:阻止默认事件、检查登录状态、检查是否正在加载、检查输入是否为空 e.preventDefault() // 阻止事件默认行为 if (!user) return toast.error('登录开启对话') if (isLoading) return toast.error('等待响应') if (!prompt.trim()) { toast.error('请输入消息') return } setIsLoading(true) // 设置加载状态为 true setPrompt('') // 清空输入框内容 const userPrompt: MessageType = { role: 'user', content: prompt, timestamp: Date.now(), } // 更新全局聊天列表 setChats(prevChats => prevChats.map(chat => chat._id === selectedChat?._id ? { ...chat, messages: [...chat.messages, userPrompt], } : chat ) ) // 更新当前选中的聊天记录 setSelectedChat(prevChat => { if (!prevChat) return null return { ...prevChat, messages: [...prevChat.messages, userPrompt], } }) // 使用fetch处理SSE请求 const response = await fetch('/api/chat/ai', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ chatId: selectedChat?._id, prompt, }), }) if (!response.ok) { throw new Error('网络请求失败') } const reader = response.body?.getReader() if (!reader) { throw new Error('无法读取响应流') } let fullContent = '' let isFirstContent = true // 标记是否是第一次收到内容 const assistantMessage: MessageType = { role: 'assistant', content: '', timestamp: Date.now(), } // 添加空的助手消息 setSelectedChat(prevChat => { if (!prevChat) return null return { ...prevChat, messages: [...prevChat.messages, assistantMessage], } }) // 处理SSE流 while (true) { const { done, value } = await reader.read() if (done) break const text = new TextDecoder().decode(value) const lines = text .split('\n\n') .filter(line => line.startsWith('data: ')) for (const line of lines) { const jsonData = JSON.parse(line.replace('data: ', '')) if (jsonData.success === false) { toast.error(jsonData.error) setPrompt(promptCopy) setIsLoading(false) // 错误时停止加载 return } if (jsonData.content) { if (isFirstContent) { setIsLoading(false) // 第一次收到内容时停止加载 isFirstContent = false } fullContent += jsonData.content // 实时更新助手消息内容 setSelectedChat(prev => { if (!prev) return null const updatedMessages = [ ...prev.messages.slice(0, -1), { ...assistantMessage, content: fullContent }, ] return { ...prev, messages: updatedMessages, } }) } // 处理最终消息 if (jsonData.success && jsonData.data) { // 更新全局聊天列表 setChats(prevChats => prevChats.map(chat => { if (chat._id === selectedChat?._id) { const updatedMessages = [...chat.messages, jsonData.data] const firstAssistantMessage = updatedMessages.find(item => item.role === 'assistant') ?.content || '' const shouldUpdateName = chat.name === '新聊天' || chat.name === '未命名聊天' || !chat.name const newName = shouldUpdateName ? firstAssistantMessage.substring(0, 12) : chat.name if (shouldUpdateName) { fetch('/api/chat/rename', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ chatId: chat._id, name: newName, }), }).catch(err => { console.error('自动命名入库失败', err) }) } return { ...chat, name: newName, messages: updatedMessages, } } return chat }) ) } } } } catch (error) { toast.error( error instanceof Error ? error.message : '发送消息失败,请重试' ) setPrompt(promptCopy) // 恢复输入内容 } finally { setIsLoading(false) } } return ( <form onSubmit={sendPrompt} className={`w-full ${ selectedChat?.messages.length ? 'max-w-3xl' : 'max-w-2xl' } bg-[#404045] p-4 rounded-3xl mt-4 transition-all`} > <textarea onKeyDown={handleKeyDown} ref={textareaRef} className="outline-none w-full resize-none overflow-y-auto break-words bg-transparent max-h-[336px] text-white" rows={2} placeholder="给 DeepSeek 发送消息" required onChange={e => setPrompt(e.target.value)} value={prompt} /> ...
这里我们DOM也改了一下,form添加了onSubmit方法,textarea添加了onKeyDown方法,这时候我们在文本框输入,看下/ai 接口是有返回的
我们去把对话显示出来,我们把page.tsx里messages那里改下
const { selectedChat } = useAppContext() // 从全局状态中获取selectedChatconst containerRef = useRef<HTMLDivElement>(null) useEffect(() => { if (selectedChat) { setMessages(selectedChat.messages) } }, [selectedChat]) // 切换对话的时候 可以到每段对话的结束 useEffect(() => { if (containerRef.current) { containerRef.current?.scrollTo({ top: containerRef.current.scrollHeight, behavior: 'smooth', }) } }) ...{messages.length === 0 ? ( <> ... ) : ( <div ref={containerRef} className="relative flex flex-col items-center justify-start w-full mt-20 max-h-screen overflow-y-auto" > <p className="fixed top-8 border border-transparent hover:border-gray-500/50 py-1 px-2 rounded-lg font-semibold mb-6" > {selectedChat?.name} </p> {messages.map((msg, index) => ( <Message key={index} role={msg.role} content={msg.content} /> ))} {isLoading && ( <div className="flex gap-4 max-w-3xl w-full py-3"> <Image className="h-9 w-9 p-1 border border-white/15 rounded-full" src={assets.logo_icon} alt="Logo" /> <div className="loader flex justify-center items-center gap-1"> <div className="w-1 h-1 rounded-full bg-white animate-bounce"></div> <div className="w-1 h-1 rounded-full bg-white animate-bounce"></div> <div className="w-1 h-1 rounded-full bg-white animate-bounce"></div> </div> </div> )} </div> )}
显示就是渲染返回的数组了messages, 我们看下页面,返回的是markdown形式,我们要处理下
渲染markdown形式
前面我们已经安装了react-markdown, 我们用这个渲染,来到Message组件, 把要渲染的content用Markdown包裹起来
import Markdown from 'react-markdown'<div className="space-y-4 w-full overflow-scroll"> <Markdown>{content}</Markdown></div>
变成这样了,样式比较美观了
代码格式渲染
我们试下代码,可以看到代码不是代码格式
前面我们安装了prismjs, 这个是语法高亮的, 要安装下pnpm i @types/prismjs -D
import Prism from 'prismjs'const Message = ({ role, content }: MessageProps) => { useEffect(() => { Prism.highlightAll() }, [content]) ...
然后要加prism.css, 在src/app文件夹下建立prism.css,
pre[class*='language-'],code[class*='language-'] { color: #d4d4d4; font-size: 13px; text-shadow: none; font-family: Menlo, Monaco, Consolas, 'Andale Mono', 'Ubuntu Mono', 'Courier New', monospace; direction: ltr; text-align: left; white-space: pre; word-spacing: normal; word-break: normal; line-height: 1.5; -moz-tab-size: 4; -o-tab-size: 4; tab-size: 4; -webkit-hyphens: none; -moz-hyphens: none; -ms-hyphens: none; hyphens: none;}pre[class*='language-']::selection,code[class*='language-']::selection,pre[class*='language-'] *::selection,code[class*='language-'] *::selection { text-shadow: none; background: #264f78;}@media print { pre[class*='language-'], code[class*='language-'] { text-shadow: none; }}pre[class*='language-'] { padding: 1em; margin: 0.5em 0; overflow: auto; background: #1e1e1e;}:not(pre) > code[class*='language-'] { padding: 0.1em 0.3em; border-radius: 0.3em; color: #db4c69; background: #1e1e1e;}/********************************************************** Tokens*/.namespace { opacity: 0.7;}.token.doctype .token.doctype-tag { color: #569cd6;}.token.doctype .token.name { color: #9cdcfe;}.token.comment,.token.prolog { color: #6a9955;}.token.punctuation,.language-html .language-css .token.punctuation,.language-html .language-javascript .token.punctuation { color: #d4d4d4;}.token.property,.token.tag,.token.boolean,.token.number,.token.constant,.token.symbol,.token.inserted,.token.unit { color: #b5cea8;}.token.selector,.token.attr-name,.token.string,.token.char,.token.builtin,.token.deleted { color: #ce9178;}.language-css .token.string.url { text-decoration: underline;}.token.operator,.token.entity { color: #d4d4d4;}.token.operator.arrow { color: #569cd6;}.token.atrule { color: #ce9178;}.token.atrule .token.rule { color: #c586c0;}.token.atrule .token.url { color: #9cdcfe;}.token.atrule .token.url .token.function { color: #dcdcaa;}.token.atrule .token.url .token.punctuation { color: #d4d4d4;}.token.keyword { color: #569cd6;}.token.keyword.module,.token.keyword.control-flow { color: #c586c0;}.token.function,.token.function .token.maybe-class-name { color: #dcdcaa;}.token.regex { color: #d16969;}.token.important { color: #569cd6;}.token.italic { font-style: italic;}.token.constant { color: #9cdcfe;}.token.class-name,.token.maybe-class-name { color: #4ec9b0;}.token.console { color: #9cdcfe;}.token.parameter { color: #9cdcfe;}.token.interpolation { color: #9cdcfe;}.token.punctuation.interpolation-punctuation { color: #569cd6;}.token.boolean { color: #569cd6;}.token.property,.token.variable,.token.imports .token.maybe-class-name,.token.exports .token.maybe-class-name { color: #9cdcfe;}.token.selector { color: #d7ba7d;}.token.escape { color: #d7ba7d;}.token.tag { color: #569cd6;}.token.tag .token.punctuation { color: #808080;}.token.cdata { color: #808080;}.token.attr-name { color: #9cdcfe;}.token.attr-value,.token.attr-value .token.punctuation { color: #ce9178;}.token.attr-value .token.punctuation.attr-equals { color: #d4d4d4;}.token.entity { color: #569cd6;}.token.namespace { color: #4ec9b0;}/********************************************************** Language Specific*/pre[class*='language-javascript'],code[class*='language-javascript'],pre[class*='language-jsx'],code[class*='language-jsx'],pre[class*='language-typescript'],code[class*='language-typescript'],pre[class*='language-tsx'],code[class*='language-tsx'] { color: #9cdcfe;}pre[class*='language-css'],code[class*='language-css'] { color: #ce9178;}pre[class*='language-html'],code[class*='language-html'] { color: #d4d4d4;}.language-regex .token.anchor { color: #dcdcaa;}.language-html .token.punctuation { color: #808080;}/********************************************************** Line highlighting*/pre[class*='language-'] > code[class*='language-'] { position: relative; z-index: 1;}.line-highlight.line-highlight { background: #f7ebc6; box-shadow: inset 5px 0 0 #f7d87c; z-index: 0;}
我的这个css文件来自prism-themes,选一个你喜欢的,把代码复制过去就行
layout.tsx要引入这个文件
import './prism.css'
这样代码就能和文本分离了
调用对话相关事件
我们现在的对话事件,是取列表的时候自动创建的;
我们需要点击对话气泡图标的时候,调用对话创建接口
const Sidebar: React.FC<SidebarProps> = ({ expand, setExpand }) => { const { openSignIn } = useClerk() const { user, chats, createNewChat, fetchUsersChats, selectedChat } = useAppContext() const [openMenu, setOpenMenu] = useState<OpenMenuState>({ id: null, open: false, }) // 创建新聊天后 聊天记录也要是新聊天的 就是说新聊天聊天记录为空 const createNew = async () => { await createNewChat() fetchUsersChats() } ...
然后我们对话有重命名、删除、根据AI 返回的内容自动重命名,我们把这些加进来ChatLabel.tsx
import React, { Dispatch, SetStateAction } from 'react'import { assets } from '@/assets/assets'import Image from 'next/image'import { useAppContext } from '@/context/AppContext'import axios from 'axios'import { OpenMenuState } from '@/types'import { toast } from 'react-hot-toast'// 使用接口定义props// 允许 id 为 string 或 null(null 表示无菜单打开)// 定义组件 Props 类型interface ChatLabelProps { openMenu: OpenMenuState setOpenMenu?: Dispatch<SetStateAction<OpenMenuState>> // 接收完整状态对象 id: string // 聊天ID name: string // 聊天名称 isSelected?: boolean // 是否为当前选中的聊天}const ChatLabel: React.FC<ChatLabelProps> = ({ openMenu, setOpenMenu, id, // 聊天ID(从父组件传入) name, // 聊天名称(从父组件传入) isSelected = false, // 是否为当前选中的聊天(默认为false)}) => { // 从全局状态获取方法和数据 const { fetchUsersChats, chats, setSelectedChat } = useAppContext() const selectChat = () => { const chatData = chats.find(chat => chat._id == id) || null if (!chatData) return setSelectedChat(chatData) } const renameHandler = async () => { try { const newName = prompt('请输入新的聊天名称') if (!newName) return const { data } = await axios.post('/api/chat/rename', { chatId: id, name: newName, }) if (data.success) { fetchUsersChats() setOpenMenu?.({ id: null, open: false }) toast.success(data.message) } else { toast.error(data.message) } } catch (error) { const message = error instanceof Error ? error.message : '重命名失败' toast.error(message) } } const deleteHandler = async () => { try { const confirm = window.confirm('确定要删除此聊天吗?') if (!confirm) return const { data } = await axios.post('/api/chat/delete', { chatId: id, }) if (data.success) { fetchUsersChats() setOpenMenu?.({ id: null, open: false }) toast.success(data.message) } else { toast.error(data.message) } } catch (error) { const message = error instanceof Error ? error.message : '删除消息失败' toast.error(message) } } const wrapperClick = (e: React.MouseEvent, id: string) => { e.stopPropagation() // 阻止事件冒泡,避免触发 selectChat setOpenMenu?.({ id, open: !openMenu.open }) // 切换菜单状态 } return ( <div onClick={selectChat} className={`flex items-center justify-between p-2 text-white/80 hover:bg-white/10 ${isSelected ? 'bg-white/10' : ''} // 选中时应用与 hover 相同的背景 rounded-lg text-sm group cursor-pointer`} > <p className="group-hover:max-w-5/6 truncate">{name}</p> <div onClick={e => wrapperClick(e, id)} className="group relative flex items-center justify-center h-6 w-6 aspect-square hover:bg-black/80 rounded-lg" > <Image src={assets.three_dots} alt="" className={`w-4 ${ openMenu.id === id && openMenu.open ? '' : 'hidden' } group-hover:block`} /> <div className={`absolute ${ openMenu.id === id && openMenu.open ? 'block' : 'hidden' } -right-32 top-6 bg-gray-700 rounded-xl w-max p-2`} > <div onClick={renameHandler} className="flex items-center gap-3 hover:bg-white/10 px-3 py-2 rounded-lg" > <Image src={assets.pencil_icon} alt="" className="w-4" /> <p>重命名</p> </div> <div className="flex items-center gap-3 hover:bg-white/10 px-3 py-2 rounded-lg" onClick={deleteHandler} > <Image src={assets.delete_icon} alt="" className="w-4" /> <p>删除</p> </div> </div> </div> </div> )}export default ChatLabel
再试一次,自动重命名正常
看下咱们的数据库数据也正常
最后小结
还有一些逻辑 可以完善的
比如用户提问,旁边按钮除了复制之外,还有编辑;就是可以基于那条内容重新编辑,让AI回答,这个简单没有去做。 就是要重新调用下AI接口,把对应的用户消息和AI内容都要改下对应的数据库记录
还有AI回答的,除了复制之外,还有刷新,喜欢,不喜欢这些也没有做;喜欢不喜欢应该是训练模型质量的。
移动端的H5点击对话按钮,应该要调新建对话接口
获取大模型流,怎么渲染出来,其实里面有一些细节,没有写出来。但是这篇笔记已经写的很长了,就先这样了:)