这篇笔记, 记录调用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)
- 使用Clerk完成用户授权
- 有了用户数据后 使用contextProvider 使其全局使用
- 完善前端页面
- 新建ChatLabel组件 便于管理不同对话
- 新建Message组件 渲染多端对话
- 使用MongoDB
- 创建项目,完成初始化流程
- 新建Collection, 建立数据库的连接
- 在Next.js里开发接口
- 用户信息接口开发
- 对话接口开发 获取用户对话列表 新建对话 删除对话 重命名接口开发
- 获取DeepSeek回复
- 在前面页面调用接口,完善逻辑
- 调用AI接口
- 使用Markdown渲染
- 代码格式高亮
- 对话标签逻辑完善
我们开始吧: )
新建项目
我们先用Next.js搭建项目框架,根据Next.js官方文档, 在控制台执行下面命令
Terminal
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,鼠标渲染浏览器标签显示的内容
tsx
export const metadata: Metadata = {
title: 'DeepSeek - 探索未至之境',
description:
'DeepSeek 是一个探索未知领域的工具,旨在帮助用户发现和理解新知识。',
}
在src目录下新建如下文件夹
- components (组件)
- config (数据库连接设置)
- context (存储共享的数据)
- models (数据库模型 Chat User)
- types (类型文件)
前端页面开发
在page.tsx写如下代码:
- 适配小屏加菜单按钮 消息按钮
- 确立页面结构 对话界面 输入框 免责声明
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文件,
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
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组件,
typescript
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
xml
...
<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里添加ClerkProvider
tsx
import { 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文件引入注册/登录
javascript
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
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, 这样我们就可以在整个应用里使用了
tsx
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()}
tsx
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组件
给聊天对话进行重命名,删除
tsx
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
tsx
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的话 对话悬浮显示复制 重新生成 喜欢 不喜欢按钮
tsx
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组件
tsx
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
js
mongodb+srv://deepseek:<db_password>@deepseek.izxme3j.mongodb.net/?retryWrites=true&w=majority&appName=DeepSeek
// mongodb+srv://<用户名>:<密码>@<集群地址>/<数据库名称>
在.env里添加变量
bash
MONGODB_URI=mongodb+srv://deepseek:deepseek@deepseek.izxme3j.mongodb.net/deepseek
允许IP都可以访问

在前面建立的config文件夹下建立db.ts文件,建立数据库连接设置
typescript
/**
* 确保整个应用生命周期中只创建一个 MongoDB 连接实例,避免重复连接导致的性能问题。
* 在开发环境中,Next.js 的热重载会重新执行代码,导致多次调用 mongoose.connect(),创建大量连接。
* 因此,使用全局变量缓存连接实例。
*/
import mongoose, { Connection } from 'mongoose'
interface Cache {
conn: Connection | null
promise: Promise<Connection> | null
}
// Extend the global object type
declare global {
let mongoose: Cache | undefined
}
// Use global variable or initialize it
const 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 (数据库模型 有哪些字段 类型)
php
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文件
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里添加一个空的
.env
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_cHJvbXB0LXNrdW5rLTY2LmNsZXJrLmFjY291bnRzLmRldiQ
CLERK_SECRET_KEY=sk_test_RXt9Gwrzhd3jcJ0FGieTeKBsrH8lsKF3YHuNhgrUPk
MONGODB_URI=mongodb+srv://deepseek:deepseek@deepseek.izxme3j.mongodb.net/deepseek
SIGNING_SECRET=''
去Vercel部署
我们先提交下代码,去Vercel部署,获取一个在线URL; 去Vercel新建项目,导入刚才提交的代码仓库

添加环境变量,点击部署

获取到了在线URL
再回到我们的Clerk dashboard.

添加Endpoint, 把我们刚才的URL加上/api/clerk, 勾选user

就有了我们要的signing secret

在Vercel里重新添加下,重新部署

我们删除注册的这个用户,重新注册下,这样新注册的用户应该就在数据库里面了

我们重新注册下

数据库里有数据了
clerk里也能看到触发的这些日志
写对话相关接口
我们先在Models下新建Chat.ts,定义需要哪些字段,校验关系
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,新增对话接口
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,获取对话接口
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,重命名对话接口
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,删除对话接口
javascript
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添加
ini
...
DEEPSEEK_API_KEY=你自己的key
这里的DeepSeek调用, 我加了stream为true,这样返回就是流
ts
export const maxDuration = 60
import 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 API
const 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里去请求
typescript
'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里有获取用户聊天记录方法,这时候我们刷新页面发现确实自动创建了新对话
因为我们前端代码逻辑写了
- 如果没有聊天,就创建聊天
- 然后重新拉取列表
ts
if (chatList.length === 0) {
// 没有聊天,自动创建
await createNewChat()
// 创建后重新拉取
const retryData = await axios.get('/api/chat/get', {
headers: { Authorization: `Bearer ${token}` },
})
...
对话也有了,只是messages为空数组,我们要在输入框里发送信息,调用AI接口,获取流并显示,来到PromptBox组件,添加sendPrompt方法
tsx
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那里改下
tsx
const { selectedChat } = useAppContext() // 从全局状态中获取selectedChat
const 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包裹起来
tsx
import Markdown from 'react-markdown'
<div className="space-y-4 w-full overflow-scroll">
<Markdown>{content}</Markdown>
</div>
变成这样了,样式比较美观了
代码格式渲染
我们试下代码,可以看到代码不是代码格式

前面我们安装了prismjs, 这个是语法高亮的, 要安装下pnpm i @types/prismjs -D
javascript
import Prism from 'prismjs'
const Message = ({ role, content }: MessageProps) => {
useEffect(() => {
Prism.highlightAll()
}, [content])
...
然后要加prism.css, 在src/app文件夹下建立prism.css,
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要引入这个文件
arduino
import './prism.css'
这样代码就能和文本分离了

调用对话相关事件
我们现在的对话事件,是取列表的时候自动创建的;
我们需要点击对话气泡图标的时候,调用对话创建接口
tsx
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
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点击对话按钮,应该要调新建对话接口
-
获取大模型流,怎么渲染出来,其实里面有一些细节,没有写出来。但是这篇笔记已经写的很长了,就先这样了:)