调用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. 获取大模型流,怎么渲染出来,其实里面有一些细节,没有写出来。但是这篇笔记已经写的很长了,就先这样了:)

相关推荐
uzong1 小时前
技术故障复盘模版
后端
GetcharZp1 小时前
基于 Dify + 通义千问的多模态大模型 搭建发票识别 Agent
后端·llm·agent
加班是不可能的,除非双倍日工资1 小时前
css预编译器实现星空背景图
前端·css·vue3
桦说编程2 小时前
Java 中如何创建不可变类型
java·后端·函数式编程
IT毕设实战小研2 小时前
基于Spring Boot 4s店车辆管理系统 租车管理系统 停车位管理系统 智慧车辆管理系统
java·开发语言·spring boot·后端·spring·毕业设计·课程设计
wyiyiyi2 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
gnip2 小时前
vite和webpack打包结构控制
前端·javascript
excel3 小时前
在二维 Canvas 中模拟三角形绕 X、Y 轴旋转
前端
阿华的代码王国3 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
一条上岸小咸鱼3 小时前
Kotlin 基本数据类型(三):Booleans、Characters
android·前端·kotlin