调用DeepSeek API实现DeepSeek网页版

这篇笔记, 记录调用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、 MongoDBClerk(用户管理)。我们按照以下流程开始

  1. 新建项目
    • 使用Next.js 新建项目
    • 引入项目资源
    • 新建一些文件夹 (components、context、config、models等)
  2. 前端页面
    • 侧边栏开发
    • 消息输入框组件开发
  3. 用户授权体系(通过Clerk)
    • 使用Clerk完成用户授权
    • 有了用户数据后 使用contextProvider 使其全局使用
  4. 完善前端页面
    • 新建ChatLabel组件 便于管理不同对话
    • 新建Message组件 渲染多端对话
  5. 使用MongoDB
    • 创建项目,完成初始化流程
    • 新建Collection, 建立数据库的连接
  6. 在Next.js里开发接口
    • 用户信息接口开发
    • 对话接口开发 获取用户对话列表 新建对话 删除对话 重命名接口开发
    • 获取DeepSeek回复
  7. 在前面页面调用接口,完善逻辑
    • 调用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的集成说明

咱们按照说明来

  1. npm install @clerk/nextjs
  2. 在.env里增加key
  3. 更新middleware.ts (在src目录下新增 clerkMiddleware 助手函数用于启用身份验证,并且其中配置受保护的路由)
  4. 在应用里添加ClerkProvider
  5. 重新运行项目 我们在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'

![image.png](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/4cacf22f0403437aa1bdbba63bababa8~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgZ29uZ3plbWlu:q75.awebp?rk3s=f64ab15b&x-expires=1753778940&x-signature=G%2Fydtj1SvYQJzirx4Q9KzxaR464%3D)
// 定义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

deepseekclass.vercel.app

再回到我们的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

再试一次,自动重命名正常

看下咱们的数据库数据也正常

最后小结

还有一些逻辑 可以完善的

  1. 比如用户提问,旁边按钮除了复制之外,还有编辑;就是可以基于那条内容重新编辑,让AI回答,这个简单没有去做。 就是要重新调用下AI接口,把对应的用户消息和AI内容都要改下对应的数据库记录

  2. 还有AI回答的,除了复制之外,还有刷新,喜欢,不喜欢这些也没有做;喜欢不喜欢应该是训练模型质量的。

  1. 移动端的H5点击对话按钮,应该要调新建对话接口

  2. 获取大模型流,怎么渲染出来,其实里面有一些细节,没有写出来。但是这篇笔记已经写的很长了,就先这样了:)

相关推荐
WanderInk40 分钟前
深入解析:Java Arrays.sort(intervals, Comparator.comparingInt(a -> a[0])); 一行代码的背后功力
java·后端·算法
Arvin6271 小时前
Nginx IP授权页面实现步骤
服务器·前端·nginx
codeGoogle1 小时前
“ASIC项目90%会失败”,黄仁勋的“诅咒”劝退华为?
后端
追逐时光者1 小时前
一款基于 .NET 开源免费、轻量快速、跨平台的 PDF 阅读器
后端·.net
xw52 小时前
Trae安装指定版本的插件
前端·trae
默默地离开3 小时前
前端开发中的 Mock 实践与接口联调技巧
前端·后端·设计模式
南岸月明3 小时前
做副业,稳住心态,不靠鸡汤!我的实操经验之路
前端
嘗_3 小时前
暑期前端训练day7——有关vue-diff算法的思考
前端·vue.js·算法
MediaTea3 小时前
Python 库手册:html.parser HTML 解析模块
开发语言·前端·python·html
杨荧3 小时前
基于爬虫技术的电影数据可视化系统 Python+Django+Vue.js
开发语言·前端·vue.js·后端·爬虫·python·信息可视化