掘金 人工智能 10小时前
调用DeepSeek API实现DeepSeek网页版
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文详细介绍了如何利用Next.js、Tailwind CSS、MongoDB和Clerk等技术栈,成功搭建一个功能完整的DeepSeek网页版应用。教程涵盖了项目初始化、前端页面设计(侧边栏、消息输入框)、用户授权体系集成、数据库连接与接口开发,以及AI响应的流式获取和渲染。通过跟随教程,用户可以学习到如何从零开始构建一个具备用户管理、对话交互和AI助手功能的现代化Web应用,并提供了GitHub仓库链接以供参考。

💰 **技术栈与架构选型**:项目采用了Next.js作为前端框架,Tailwind CSS进行样式设计,MongoDB作为数据库,并集成了Clerk实现用户认证和管理。这种组合提供了高效的开发体验和强大的功能支持,为构建现代Web应用奠定了坚实基础。

🎨 **前端界面与交互设计**:文章详细拆解了前端页面的开发,包括响应式侧边栏的实现,用户友好的消息输入框设计,以及对话列表的管理组件(ChatLabel)和消息展示组件(Message)。重点在于通过组件化开发,提升代码的可维护性和复用性,并注重用户体验的细节优化。

🛡️ **用户认证与数据管理**:利用Clerk简化了用户注册和登录流程,通过Context API实现了用户数据的全局共享,确保了用户信息的安全与便捷访问。这为后续的用户特定功能开发提供了基础。

🚀 **后端接口与AI集成**:文章阐述了如何在Next.js中开发API接口,用于处理用户信息、对话列表、新建、删除和重命名等操作。特别强调了如何通过流式传输(SSE)获取DeepSeek API的响应数据,并使用Markdown和代码高亮进行优雅展示,提升了AI交互的实时性和用户体验。

🔗 **开源与部署**:提供了项目的GitHub仓库链接,方便用户获取代码资源和自行部署。文章提及了使用Vercel进行部署,并提供了在线体验链接,鼓励用户亲自尝试和探索。

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

    新建项目
      使用Next.js 新建项目引入项目资源新建一些文件夹 (components、context、config、models等)
    前端页面
      侧边栏开发消息输入框组件开发
    用户授权体系(通过Clerk)
      使用Clerk完成用户授权有了用户数据后 使用contextProvider 使其全局使用
    完善前端页面
      新建ChatLabel组件 便于管理不同对话新建Message组件 渲染多端对话
    使用MongoDB
      创建项目,完成初始化流程新建Collection, 建立数据库的连接
    在Next.js里开发接口
      用户信息接口开发对话接口开发 获取用户对话列表 新建对话 删除对话 重命名接口开发获取DeepSeek回复
    在前面页面调用接口,完善逻辑
      调用AI接口使用Markdown渲染代码格式高亮对话标签逻辑完善

我们开始吧: )

新建项目

我们先用Next.js搭建项目框架,根据Next.js官方文档,在控制台执行下面命令

npx create-next-app@latest

上面命令会启动引导程序,我全部选择默认选项,比如使用TypeScript, 使用ESLint, Tailwind CSS, 使用App Router

app文件夹下面有layout.tsx, page.tsx这时候就可以运行npm run dev把项目跑起来了

引入项目资源及一些配置

在前面给出了Github的地址,我们把要用到的图标assets文件夹拿过来,放到src目录下,更新layout.tsx里面的metadata,鼠标渲染浏览器标签显示的内容

export const metadata: Metadata = {  title: 'DeepSeek - 探索未至之境',  description:    'DeepSeek 是一个探索未知领域的工具,旨在帮助用户发现和理解新知识。',}

在src目录下新建如下文件夹

前端页面开发

在page.tsx写如下代码:

'use client'import Image from 'next/image'import { assets } from '@/assets/assets'import { useState } from 'react'interface MessageType {  role: 'user' | 'assistant'  content: string  timestamp: number}export default function Home() {  const [expand, setExpand] = useState(false);  const [messages, setMessages] = useState<MessageType[]>([]);  const [isLoading, setIsLoading] = useState(false);  return (    <div className="flex h-screen">      {/* 左侧导航栏 */}      <div        className="flex-1 flex flex-col items-center justify-center px-4 pb-8 bg-[#292a2d]       text-white relative"      >        <div className="md:hidden absolute px-4 top-6 flex items-center justify-between w-full">          <Image            onClick={() => (expand ? setExpand(false) : setExpand(true))}            className="rotate-180"            src={assets.menu_icon}            alt=""          />          <Image className="opacity-70" src={assets.chat_icon} alt="" />        </div>        {messages.length === 0 ? (          <>            <div className="flex items-center gap-3">              <Image src={assets.logo_icon} alt="" className="h-16" />              <p className="text-2xl font-medium">                我是 DeepSeek,很高兴见到你!              </p>            </div>            <p className="text-sm mt-2">              我可以帮你写代码、读文件、写作各种创意内容,请把你的任务交给我吧~            </p>          </>        ) : (          <div></div>        )}          {/* 对话输入框 */}          <p className="text-xs absolute bottom-1 text-gray-500">            内容由 AI 生成,请仔细甄别          </p>      </div>    </div>  )}

启动项目可以看到如下效果 (移动端多了我们加的适配 菜单 对话图标)

写一个侧边栏Sidebar.tsx文件,

import React from 'react'import Image from 'next/image'import { assets } from '@/assets/assets'interface SidebarProps {  expand: boolean  setExpand: (value: boolean) => void}const createNew = () => {}const Sidebar: React.FC<SidebarProps> = ({ expand, setExpand }) => {  return (    <div      className={`flex flex-col justify-between bg-[#212327] pt-7 transition-all      z-50 max-md:absolute max-md:h-screen ${       expand ? 'p-4 w-64' : 'md:w-20 w-0 max-md:overflow-hidden'     }`}    >      <div>        <div          className={`flex ${            expand ? 'flex-row gap-10' : 'flex-col items-center gap-8'          }`}        >          <Image            className={expand ? 'w-36' : 'w-10'}            src={expand ? assets.logo_text : assets.logo_icon}            alt=""          />          <div            onClick={() => (expand ? setExpand(false) : setExpand(true))}            className="group relative flex items-center justify-center hover:bg-gray-500/20 transition-all           duration-300 h-9 w-9 aspect-square rounded-lg cursor-pointer"          >            <Image              src={assets.menu_icon}              alt="菜单图标"              className="md:hidden"            />            <Image              src={expand ? assets.sidebar_close_icon : assets.sidebar_icon}              alt="展开/收起边栏"              className="hidden md:block cursor-pointer w-7 h-7"            />            <div              className={`absolute w-max ${                expand ? 'left-1/2 -translate-x-1/2 top-12' : '-top-12 left-0'              }             opacity-0 group-hover:opacity-100 transition bg-black text-white text-sm px-3 py-2 rounded-lg shadow-lg pointer-events-none`}            >              {expand ? '收起边栏' : '打开边栏'}              <div                className={`w-3 h-3 absolute bg-black rotate-45 ${                  expand                    ? 'left-1/2 -top-1.5 -translate-x-1/2'                    : 'left-4 -bottom-1.5'                }`}              ></div>            </div>          </div>        </div>        <button          onClick={createNew}          className={`mt-8 flex items-center justify-center cursor-pointer         ${           expand             ? 'bg-primary hover:opacity-90 rounded-2xl gap-2 p-2.5 w-max'             : 'group relative h-9 w-9 mx-auto hover:bg-gray-500/30 rounded-lg'         }`}        >          <Image            className={expand ? 'w-6' : 'w-7'}            src={expand ? assets.chat_icon : assets.chat_icon_dull}            alt=""          />          <div            className="absolute w-max -top-12 -right-12 opacity-0 group-hover:opacity-100 transition             bg-black text-white text-sm px-3 py-2 rounded-lg shadow-lg pointer-events-none"          >            开启新对话            <div className="w-3 h-3 absolute bg-black rotate-45 left-4 -bottom-1.5"></div>          </div>          {expand && <p className="text-white text font-medium">开启新对话</p>}        </button>        <div          className={`mt-8 text-white/25 text-sm ${            expand ? 'block' : 'hidden'          }`}        >          <p className="my-1">最近</p>          {/* chatLabel 可以替换为实际的聊天标签数据 */}        </div>      </div>      <div>        <div          className={`flex items-center cursor-pointer group relative ${            expand              ? 'gap-1 text-white/80 text-sm p-2.5 border border-primay rounded-lg hover:bg-white/10 cursor-pointer'              : 'h-10 w-10 mx-auto hover:bg-gray-500/30 rounded-lg'          }`}        >          <Image            className={expand ? 'w-5' : 'w-6.5 mx-auto'}            src={expand ? assets.phone_icon : assets.phone_icon_dull}            alt=""          />          <div            className={`absolute -top-60 pb-8 ${              !expand && '-right-40'            } opacity-0 group-hover:opacity-100           hidden group-hover:block transition`}          >            <div className="relative w-max bg-black text-white text-sm p-3 rounded-lg shadow-lg">              <Image src={assets.qrcode} alt="" className="w-44" />              <p>扫码下载 DeepSeek APP</p>              <div                className={`w-3 h-3 absolute bg-black rotate-45 ${                  expand ? 'right-1/2' : 'left-4'                } -bottom-1.5`}              ></div>            </div>          </div>          {expand && (            <>              <span>下载 App</span>              <Image alt="" src={assets.new_icon} />            </>          )}        </div>        <div          className={`flex items-center ${            expand ? 'hover:bg-white/10 rounded-lg' : 'justify-center w-full'          } gap-3         text-white/60 text-sm p-2 mt-2 cursor-pointer`}        >          <Image src={assets.profile_icon} alt="" className="w-7" />          {expand && <span>个人信息</span>}        </div>      </div>    </div>  )}export default Sidebar

引入到page.tsx

import Sidebar from '@/components/Sidebar'export default function Home() {  return (    <div className="flex h-screen">      <Sidebar expand={expand} setExpand={setExpand} />      ...

代码主要分为2部分

消息输入框组件开发

新建一个PromptBox组件,

import React, {  useState,  useRef,  useEffect,  KeyboardEvent,  FormEvent,} from 'react'import Image from 'next/image'import { assets } from '@/assets/assets'// 定义组件 props 类型interface PromptBoxProps {  setIsLoading: (isLoading: boolean) => void  isLoading: boolean}const PromptBox: React.FC<PromptBoxProps> = ({ setIsLoading, isLoading }) => {  const [prompt, setPrompt] = useState<string>('')  const textareaRef = useRef<HTMLTextAreaElement>(null)  return (    <form      className={`w-full ${        true ? 'max-w-3xl' : 'max-w-2xl'      } bg-[#404045] p-4 rounded-3xl mt-4 transition-all`}    >      <textarea        ref={textareaRef}        className="outline-none w-full resize-none overflow-y-auto break-words bg-transparent max-h-[336px] text-white"        rows={2}        placeholder="给 DeepSeek 发送消息"        required        onChange={e => setPrompt(e.target.value)}        value={prompt}      />      <div className="flex items-center justify-between text-sm">        <div className="flex items-center gap-2">          <p            className="flex items-center gap-2 text-xs border border-gray-300/40 px-2 py-1 rounded-full cursor-pointer             hover:bg-gray-500/20 transition"          >            <Image className="h-5" src={assets.deepthink_icon} alt="" />            深度思考 (R1)          </p>          <p            className="flex items-center gap-2 text-xs border border-gray-300/40 px-2 py-1 rounded-full cursor-pointer             hover:bg-gray-500/20 transition"          >            <Image className="h-5" src={assets.search_icon} alt="" />            联网搜索          </p>        </div>        <div className="flex items-center gap-2">          <Image className="w-4 cursor-pointer" src={assets.pin_icon} alt="" />          <button            className={`${prompt ? 'bg-primary' : 'bg-[#71717a]'}           rounded-full p-2 cursor-pointer`}          >            <Image              className="w-3.5 aspect-square"              src={prompt ? assets.arrow_icon : assets.arrow_icon_dull}              alt=""            />          </button>        </div>      </div>    </form>  )}export default PromptBox

引入到Page.tsx

...    <PromptBox isLoading={isLoading} setIsLoading={setIsLoading} /><p className="text-xs absolute bottom-1 text-gray-500">  内容由 AI 生成,请仔细甄别</p>

到此我们前端页面就告一段落了,可以着手功能开发了

利用Clerk实现用户授权体系

新建项目

可以看到有我们项目用到的Next.js的集成说明

咱们按照说明来

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

import { useClerk, UserButton } from '@clerk/nextjs'const createNew = () => {}const Sidebar: React.FC<SidebarProps> = ({ expand, setExpand }) => {  const { openSignIn } = useClerk()  return (   ...   <div      onClick={() => openSignIn()}      className={`flex items-center ${        expand ? 'hover:bg-white/10 rounded-lg' : 'justify-center w-full'      } gap-3     text-white/60 text-sm p-2 mt-2 cursor-pointer`}    >      <Image src={assets.profile_icon} alt="" className="w-7" />      {expand && <span>个人信息</span>}    </div>  )

这时候就能弹出Clerk的注册/登录了,说明我们引入成功了

在Context文件夹下新建AppContext.tsx

因为用户信息我们需要在不同组件里共享,所以我们放在AppContext, 新建的AppContext.tsx

'use client'import React, { createContext, useContext, ReactNode } from 'react'import { useUser } from '@clerk/nextjs'type UserType = ReturnType<typeof useUser>['user']interface AppContextType {  user: UserType}interface AppContextType {  user: UserType}export const AppContext = createContext<AppContextType>({  user: null,})export const useAppContext = () => useContext(AppContext)interface AppContextProviderProps {  children: ReactNode}export const AppContextProvider = ({ children }: AppContextProviderProps) => {  const { user } = useUser()  const value: AppContextType = {    user,  }dance  return <AppContext.Provider value={value}>{children}</AppContext.Provider>}

然后我们在Layout.tsx, 引入AppContext.Provider, 这样我们就可以在整个应用里使用了

import { ClerkProvider } from '@clerk/nextjs'...export default function RootLayout({  children,}: Readonly<{  children: React.ReactNode}>) {  return (    <ClerkProvider>      <AppContextProvider>        <html lang="en">         ...        </html>      </AppContextProvider>    </ClerkProvider>

改造下Sidebar, 如果有用户信息就显示,点击的时候如果有用户信息就不要登录了, onClick={user ? undefined : () => openSignIn()}

import { useAppContext } from '@/context/AppContext'...const Sidebar: React.FC<SidebarProps> = ({ expand, setExpand }) => {  const { openSignIn } = useClerk()  const { user } = useAppContext()  ...   <div      onClick={user ? undefined : () => openSignIn()}      className={`flex items-center ${        expand ? 'hover:bg-white/10 rounded-lg' : 'justify-center w-full'      } gap-3     text-white/60 text-sm p-2 mt-2 cursor-pointer`}    >      {user ? (        <UserButton />      ) : (        <Image src={assets.profile_icon} alt="" className="w-7" />      )}      {expand && <span>个人信息</span>}</div>

点击注册

完善前端页面

我们的页面还欠缺用户对话管理,还没有用户对话组件

新建ChatLabel组件

给聊天对话进行重命名,删除

import React, { Dispatch, SetStateAction } from 'react'import { assets } from '@/assets/assets'import Image from 'next/image'export type OpenMenuState = {  open: boolean}// 定义组件 Props 类型interface ChatLabelProps {  openMenu: OpenMenuState  setOpenMenu?: Dispatch<SetStateAction<OpenMenuState>> // 接收完整状态对象}const ChatLabel: React.FC<ChatLabelProps> = ({ openMenu, setOpenMenu }) => {  const renameHandler = async () => {}  const deleteHandler = async () => {}  const wrapperClick = (e: React.MouseEvent) => {    e.stopPropagation() // 阻止事件冒泡,避免触发 selectChat    setOpenMenu?.({ open: !openMenu.open }) // 切换菜单状态  }  return (    <div      className={`flex items-center justify-between p-2 text-white/80          hover:bg-white/10           rounded-lg text-sm group cursor-pointer`}    >      <p className="group-hover:max-w-5/6 truncate">新聊天</p>      <div        className="group relative flex items-center justify-center h-6 w-6 aspect-square        hover:bg-black/80 rounded-lg"        onClick={e => wrapperClick(e)}      >        <Image          src={assets.three_dots}          alt=""          className={`w-4 ${            openMenu.open ? 'block' : 'hidden'          } group-hover:block`}        />        <div          className={`absolute  ${            openMenu.open ? 'block' : 'hidden'          } -right-32 top-6 bg-gray-700 rounded-xl w-max p-2`}        >          <div            onClick={renameHandler}            className="flex items-center gap-3 hover:bg-white/10 px-3 py-2 rounded-lg"          >            <Image src={assets.pencil_icon} alt="" className="w-4" />            <p>重命名</p>          </div>          <div            className="flex items-center gap-3 hover:bg-white/10 px-3 py-2 rounded-lg"            onClick={deleteHandler}          >            <Image src={assets.delete_icon} alt="" className="w-4" />            <p>删除</p>          </div>        </div>      </div>    </div>  )}export default ChatLabel

在Sidebar引入ChatLabel

import ChatLabel from './ChatLabel'type OpenMenuState = {  open: boolean}const Sidebar: React.FC<SidebarProps> = ({ expand, setExpand }) => {  ...  const [openMenu, setOpenMenu] = useState<OpenMenuState>({    open: false,  })   return (   ...    <div      className={`mt-8 text-white/25 text-sm ${        expand ? 'block' : 'hidden'      }`}    >      <p className="my-1">最近</p>      <ChatLabel openMenu={openMenu} setOpenMenu={setOpenMenu} />    </div>    ... )

鼠标悬浮高亮 出现3个点 点击三个点图标弹出对话框

新建Message组件
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组件

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

新建好了,咱们重新跑下项目,去mongoDB网站新建项目,有项目直接新建Collection

新建项目

去新建Cluster

选择免费版本

去连接 设置安全设置 只允许本地IP连接

选择连接方法

Mongoose方式

复制下这里的URL

mongodb+srv://deepseek:<db_password>@deepseek.izxme3j.mongodb.net/?retryWrites=true&w=majority&appName=DeepSeek// mongodb+srv://<用户名>:<密码>@<集群地址>/<数据库名称>

在.env里添加变量

MONGODB_URI=mongodb+srv://deepseek:deepseek@deepseek.izxme3j.mongodb.net/deepseek

允许IP都可以访问

在前面建立的config文件夹下建立db.ts文件,建立数据库连接设置

/** * 确保整个应用生命周期中只创建一个 MongoDB 连接实例,避免重复连接导致的性能问题。 * 在开发环境中,Next.js 的热重载会重新执行代码,导致多次调用 mongoose.connect(),创建大量连接。 * 因此,使用全局变量缓存连接实例。 */import mongoose, { Connection } from 'mongoose'interface Cache {  conn: Connection | null  promise: Promise<Connection> | null}// Extend the global object typedeclare global {  let mongoose: Cache | undefined}// Use global variable or initialize itconst globalWithMongoose = global as typeof globalThis & { mongoose?: Cache }const cached: Cache = globalWithMongoose.mongoose || {  conn: null,  promise: null,}export default async function connectDB(): Promise<Connection> {  if (cached.conn) return cached.conn  if (!cached.promise) {    cached.promise = mongoose      .connect(process.env.MONGODB_URI!)      .then(m => m.connection)  }  try {    cached.conn = await cached.promise    globalWithMongoose.mongoose = cached    return cached.conn  } catch (err) {    cached.promise = null    throw err  }}

在Models文件夹下新建User.ts (数据库模型 有哪些字段 类型)

import mongoose from 'mongoose'const UserSchema = new mongoose.Schema(  {    _id: { type: String, required: true },    name: { type: String, required: true },    email: { type: String, required: true },    image: { type: String, required: false },  },  { timestamps: true })const User = mongoose.models.User || mongoose.model('User', UserSchema)export default User

在src/app 下新建api/clerk/route.ts文件

import { Webhook } from 'svix'import connectDB from '@/config/db'import User from '@/models/User'import { headers } from 'next/headers'import { NextRequest, NextResponse } from 'next/server'interface SvixEvent {  data: {    id: string    email_addresses: { email_address: string }[]    first_name: string    last_name: string    image_url: string  }  type: 'user.created' | 'user.updated' | 'user.deleted'}export async function POST(req: NextRequest) {  // 验证环境变量是否存在  const signingSecret = process.env.SIGNING_SECRET  if (!signingSecret) {    return NextResponse.json(      { error: '没有找到 SIGNING_SECRET 环境变量' },      { status: 500 }    )  }  const wh = new Webhook(signingSecret)  const headerPayload = await headers()  // 获取必要的 Svix 头部信息  const svixId = headerPayload.get('svix-id')  const svixTimestamp = headerPayload.get('svix-timestamp')  const svixSignature = headerPayload.get('svix-signature')  if (!svixId || !svixTimestamp || !svixSignature) {    return NextResponse.json(      { error: '没有找到必要的 Svix 头部信息' },      { status: 400 }    )  }  const svixHeaders = {    'svix-id': svixId,    'svix-timestamp': svixTimestamp,    'svix-signature': svixSignature,  }  // 获取请求体并转换为字符串  const payload = await req.json()  const body = JSON.stringify(payload)  // 验证请求的有效性  const { data, type } = wh.verify(body, svixHeaders) as SvixEvent  // 准备用户数据  const userData = {    _id: data.id,    email: data.email_addresses[0].email_address,    name: `${data.first_name} ${data.last_name}`,    image: data.image_url,  }  await connectDB()  // 处理不同类型的事件  switch (type) {    case 'user.created':      await User.create(userData)      break    case 'user.updated':      await User.findByIdAndUpdate(data.id, userData)      break    case 'user.deleted':      await User.findByIdAndDelete(data.id)      break    default:      break  }  return NextResponse.json({    message: 'Event received',  })}

代码里的user.created, user.updated, user.deleted都来自这里

代码里用到的SIGNING_SECRET目前还没有,我们先在.env里添加一个空的

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_cHJvbXB0LXNrdW5rLTY2LmNsZXJrLmFjY291bnRzLmRldiQCLERK_SECRET_KEY=sk_test_RXt9Gwrzhd3jcJ0FGieTeKBsrH8lsKF3YHuNhgrUPkMONGODB_URI=mongodb+srv://deepseek:deepseek@deepseek.izxme3j.mongodb.net/deepseekSIGNING_SECRET=''
去Vercel部署

我们先提交下代码,去Vercel部署,获取一个在线URL;去Vercel新建项目,导入刚才提交的代码仓库

添加环境变量,点击部署

获取到了在线URL

deepseekclass.vercel.app

再回到我们的Clerk dashboard.

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

就有了我们要的signing secret

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

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

我们重新注册下

数据库里有数据了

clerk里也能看到触发的这些日志

写对话相关接口

我们先在Models下新建Chat.ts,定义需要哪些字段,校验关系

import mongoose from 'mongoose'const ChatSchema = new mongoose.Schema(  {    name: { type: String, required: true },    messages: [      {        role: { type: String, required: true },        content: { type: String, required: true },        createdAt: { type: Date, default: Date.now },      },    ],    userId: { type: String, required: true },  },  { timestamps: true })const Chat = mongoose.models.Chat || mongoose.model('Chat', ChatSchema)export default Chat

然后在src/app/api/文件夹下新建chat/create/route.ts,新增对话接口

import connectDB from '@/config/db'import Chat from '@/models/Chat'import { getAuth } from '@clerk/nextjs/server'import { NextRequest, NextResponse } from 'next/server'export async function POST(req: NextRequest) {  try {    const { userId } = getAuth(req)    if (!userId) {      return NextResponse.json({        success: false,        message: '用户未授权',      })    }    const chatData = {      userId,      messages: [],      name: '新聊天', // Default chat name    }    await connectDB()    await Chat.create(chatData)    return NextResponse.json({      success: true,      message: '聊天创建成功',    })  } catch (error: unknown) {    let errorMessage = '创建聊天失败'    if (error instanceof Error) {      errorMessage = error.message    }    return NextResponse.json(      {        success: false,        error: errorMessage,      },      { status: 500 }    )  }}

新建chat/get/route.ts,获取对话接口

import connectDB from '@/config/db'import Chat from '@/models/Chat'import { NextRequest, NextResponse } from 'next/server'import { getAuth } from '@clerk/nextjs/server'export async function GET(request: NextRequest) {  try {    const { userId } = getAuth(request)    if (!userId) {      return NextResponse.json({ message: '用户未授权', success: false })    }    await connectDB()    const data = await Chat.find({ userId })    return NextResponse.json({ success: true, data }) // return NextResponse.json(chats, { status: 200 })  } catch (error: unknown) {    let errorMessage = ''    if (error instanceof Error) {      errorMessage = error.message    }    return NextResponse.json({ message: errorMessage, success: false })  }}

新建chat/rename/route.ts,重命名对话接口

import connectDB from '@/config/db'import Chat from '@/models/Chat'import { getAuth } from '@clerk/nextjs/server'import { NextRequest, NextResponse } from 'next/server'export async function POST(req: NextRequest) {  try {    const { userId } = getAuth(req)    if (!userId) {      return NextResponse.json({        success: false,        message: '用户未授权',      })    }    const { chatId, name } = await req.json()    await connectDB()    await Chat.findOneAndUpdate({ _id: chatId, userId }, { name })    return NextResponse.json({      success: true,      message: '聊天重命名成功',    })  } catch (error: unknown) {    let errorMessage = '重命名聊天失败'    if (error instanceof Error) {      errorMessage = error.message    }    return NextResponse.json(      {        success: false,        error: errorMessage,      },      { status: 500 }    )  }}

新建chat/delete/route.ts,删除对话接口

import connectDB from '@/config/db'import Chat from '@/models/Chat'import { getAuth } from '@clerk/nextjs/server'import { NextRequest, NextResponse } from 'next/server'export async function POST(req: NextRequest) {  try {    const { userId } = getAuth(req)    if (!userId) {      return NextResponse.json({        success: false,        message: '用户未授权',      })    }    const { chatId } = await req.json()    // Connect to the database and delete the chat    await connectDB()    await Chat.findOneAndDelete({ _id: chatId, userId })    return NextResponse.json({      success: true,      message: '聊天删除成功',    })  } catch (error: unknown) {    let errorMessage = '删除聊天失败'    if (error instanceof Error) {      errorMessage = error.message    }    return NextResponse.json(      {        success: false,        error: errorMessage,      },      { status: 500 }    )  }}

新建chat/ai/route.ts,获取AI回复接口
根据deepseek 文档,调用deepseek API,需要安装openai, 前面我们已经安装过了

去这个页面取key

在.env添加

...DEEPSEEK_API_KEY=你自己的key

这里的DeepSeek调用, 我加了stream为true,这样返回就是流

export const maxDuration = 60import Chat from '@/models/Chat'import OpenAI from 'openai'import { getAuth } from '@clerk/nextjs/server'import { NextRequest, NextResponse } from 'next/server'import connectDB from '@/config/db'interface ExtendedChatMessage extends OpenAI.ChatCompletionMessage {  timestamp: number}// 初始化OpenAI客户端,配置DeepSeek APIconst openai = new OpenAI({  baseURL: 'https://api.deepseek.com',  apiKey: process.env.DEEPSEEK_API_KEY || '',})// 处理POST请求export async function POST(req: NextRequest) {  try {    const { userId } = getAuth(req) // 从请求中获取用户ID    // 从请求体中提取chatId和prompt    const { chatId, prompt } = await req.json()    // 检查用户是否授权    if (!userId) {      return NextResponse.json({        success: false,        message: '用户未授权',      })    }    // 连接数据库并查找聊天记录    await connectDB()    const data = await Chat.findOne({ userId, _id: chatId })    // 创建用户消息对象    const userPrompt = {      role: 'user',      content: prompt,      timestamp: Date.now(), // 添加时间戳    }    data.messages.push(userPrompt) // 将用户消息添加到聊天记录    await data.save()    // 设置SSE响应头    const headers = {      'Content-Type': 'text/event-stream',      'Cache-Control': 'no-cache',      Connection: 'keep-alive',    }    // 创建可写流来处理SSE    const stream = new ReadableStream({      async start(controller) {        try {          // 调用DeepSeek API获取流式聊天回复          const completion = await openai.chat.completions.create({            messages: [{ role: 'user', content: prompt }],            model: 'deepseek-chat',            stream: true,            store: true,          })          let fullContent = ''          // 处理流式响应          for await (const chunk of completion) {            const content = chunk.choices[0]?.delta?.content || ''            if (content) {              fullContent += content              // 发送SSE事件              controller.enqueue(                new TextEncoder().encode(                  `data: ${JSON.stringify({ content })}\n\n`                )              )            }          }          // 保存AI回复到数据库          const message = {            role: 'assistant',            content: fullContent,            timestamp: Date.now(),          } as ExtendedChatMessage          data.messages.push(message)          await data.save()          // 发送最终消息          controller.enqueue(            new TextEncoder().encode(              `data: ${JSON.stringify({ success: true, data: message })}\n\n`            )          )          // 关闭流          controller.close()        } catch (error) {          // 错误处理          let errorMessage = '聊天回复失败'          if (error instanceof Error) {            errorMessage = error.message          }          controller.enqueue(            new TextEncoder().encode(              `data: ${JSON.stringify({                success: false,                error: errorMessage,              })}\n\n`            )          )          controller.close()        }      },    })    return new NextResponse(stream, { headers })  } catch (error) {    // 初始错误处理    let errorMessage = '聊天回复失败'    if (error instanceof Error) {      errorMessage = error.message    }    return NextResponse.json({      success: false,      error: errorMessage,    })  }}
前端调用接口

因为用户,聊天数据我们多个组件都要用到,我们在AppContext.tsx里去请求

'use client'import React, {  createContext,  useContext,  ReactNode,  useState,  useEffect,  useCallback,} from 'react'import { useUser, useAuth } from '@clerk/nextjs'import axios from 'axios'import toast from 'react-hot-toast'type UserType = ReturnType<typeof useUser>['user']import { MessageType } from '@/types'// 聊天记录类型interface ChatType {  _id: string // 对应 MongoDB 的 ObjectId(字符串类型)  updatedAt: string // ISO 日期字符串(推荐)或 Date 类型  messages: MessageType[]  name?: string}interface AppContextType {  user: UserType  chats: ChatType[]  setChats: React.Dispatch<React.SetStateAction<ChatType[]>>  selectedChat: ChatType | null  setSelectedChat: React.Dispatch<React.SetStateAction<ChatType | null>>  fetchUsersChats: () => Promise<void>  createNewChat: () => Promise<void>}export const AppContext = createContext<AppContextType>({  user: null,  chats: [],  setChats: () => {},  selectedChat: null,  setSelectedChat: () => {},  fetchUsersChats: async () => {},  createNewChat: async () => {},})export const useAppContext = () => useContext(AppContext)interface AppContextProviderProps {  children: ReactNode}export const AppContextProvider = ({ children }: AppContextProviderProps) => {  const { user } = useUser()  const { getToken } = useAuth()  const [chats, setChats] = useState<ChatType[]>([])  const [selectedChat, setSelectedChat] = useState<ChatType | null>(null)  // 🔗 创建新聊天  const createNewChat = useCallback(async () => {    try {      if (!user) return      const token = await getToken()      await axios.post(        '/api/chat/create',        {},        {          headers: {            Authorization: `Bearer ${token}`,          },        }      )      toast.success('新聊天创建成功')      // 不需要直接调用 fetchUsersChats,这会在调用者自己处理    } catch (error) {      const message = error instanceof Error ? error.message : '创建新聊天失败'      toast.error(message)    }  }, [user, getToken])  // 🔗 获取用户聊天列表  const fetchUsersChats = useCallback(async () => {    try {      if (!user) return      const token = await getToken()      const { data } = await axios.get('/api/chat/get', {        headers: { Authorization: `Bearer ${token}` },      })      if (data.success) {        const chatList: ChatType[] = data.data        if (chatList.length === 0) {          // 没有聊天,自动创建          await createNewChat()          // 创建后重新拉取          const retryData = await axios.get('/api/chat/get', {            headers: { Authorization: `Bearer ${token}` },          })          const retryChatList: ChatType[] = retryData.data.data || []          setChats(retryChatList)          if (retryChatList.length > 0) {            const sortedChats = [...retryChatList].sort(              (a, b) =>                new Date(b.updatedAt).getTime() -                new Date(a.updatedAt).getTime()            )            setSelectedChat(sortedChats[0])          }          return        }        const sortedChats = [...chatList].sort(          (a, b) =>            new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()        )        setChats(sortedChats)        setSelectedChat(sortedChats[0])      } else {        toast.error(data.message || '获取聊天列表失败')      }    } catch (error) {      const message =        error instanceof Error ? error.message : '获取聊天列表失败'      toast.error(message)    }  }, [user, getToken, createNewChat])  // 👀 自动拉取聊天列表  useEffect(() => {    if (user) {      fetchUsersChats()    }  }, [user, fetchUsersChats])  const value: AppContextType = {    user,    chats,    setChats,    selectedChat,    setSelectedChat,    fetchUsersChats,    createNewChat,  }  return <AppContext.Provider value={value}>{children}</AppContext.Provider>}

现在接口有了,调接口复用方法也写好了。因为layout.ts用了AppContextProvider,这个组件useEffect里有获取用户聊天记录方法,这时候我们刷新页面发现确实自动创建了新对话

因为我们前端代码逻辑写了

if (chatList.length === 0) {      // 没有聊天,自动创建      await createNewChat()      // 创建后重新拉取      const retryData = await axios.get('/api/chat/get', {        headers: { Authorization: `Bearer ${token}` },      })      ...

对话也有了,只是messages为空数组,我们要在输入框里发送信息,调用AI接口,获取流并显示,来到PromptBox组件,添加sendPrompt方法

const PromptBox: React.FC<PromptBoxProps> = ({ setIsLoading, isLoading }) => {  const [prompt, setPrompt] = useState<string>('')  const textareaRef = useRef<HTMLTextAreaElement>(null)  const { user, setChats, selectedChat, setSelectedChat } = useAppContext() // 全局状态  const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {    // 如果按下的是回车键且没有按住 Shift 键,则发送消息    // Shift + Enter 用于换行    if (e.key === 'Enter' && !e.shiftKey) {      e.preventDefault()      sendPrompt(e)    }  }  const sendPrompt = async (    e: FormEvent<HTMLFormElement> | KeyboardEvent<HTMLTextAreaElement>  ) => {    const promptCopy = prompt // 缓存当前输入的prompt,用于后续错误恢复    try {      // 前置校验:阻止默认事件、检查登录状态、检查是否正在加载、检查输入是否为空      e.preventDefault() // 阻止事件默认行为      if (!user) return toast.error('登录开启对话')      if (isLoading) return toast.error('等待响应')      if (!prompt.trim()) {        toast.error('请输入消息')        return      }      setIsLoading(true) // 设置加载状态为 true      setPrompt('') // 清空输入框内容      const userPrompt: MessageType = {        role: 'user',        content: prompt,        timestamp: Date.now(),      }      // 更新全局聊天列表      setChats(prevChats =>        prevChats.map(chat =>          chat._id === selectedChat?._id            ? {                ...chat,                messages: [...chat.messages, userPrompt],              }            : chat        )      )      // 更新当前选中的聊天记录      setSelectedChat(prevChat => {        if (!prevChat) return null        return {          ...prevChat,          messages: [...prevChat.messages, userPrompt],        }      })      // 使用fetch处理SSE请求      const response = await fetch('/api/chat/ai', {        method: 'POST',        headers: {          'Content-Type': 'application/json',        },        body: JSON.stringify({          chatId: selectedChat?._id,          prompt,        }),      })      if (!response.ok) {        throw new Error('网络请求失败')      }      const reader = response.body?.getReader()      if (!reader) {        throw new Error('无法读取响应流')      }      let fullContent = ''      let isFirstContent = true // 标记是否是第一次收到内容      const assistantMessage: MessageType = {        role: 'assistant',        content: '',        timestamp: Date.now(),      }      // 添加空的助手消息      setSelectedChat(prevChat => {        if (!prevChat) return null        return {          ...prevChat,          messages: [...prevChat.messages, assistantMessage],        }      })      // 处理SSE流      while (true) {        const { done, value } = await reader.read()        if (done) break        const text = new TextDecoder().decode(value)        const lines = text          .split('\n\n')          .filter(line => line.startsWith('data: '))        for (const line of lines) {          const jsonData = JSON.parse(line.replace('data: ', ''))          if (jsonData.success === false) {            toast.error(jsonData.error)            setPrompt(promptCopy)            setIsLoading(false) // 错误时停止加载            return          }          if (jsonData.content) {            if (isFirstContent) {              setIsLoading(false) // 第一次收到内容时停止加载              isFirstContent = false            }            fullContent += jsonData.content            // 实时更新助手消息内容            setSelectedChat(prev => {              if (!prev) return null              const updatedMessages = [                ...prev.messages.slice(0, -1),                { ...assistantMessage, content: fullContent },              ]              return {                ...prev,                messages: updatedMessages,              }            })          }          // 处理最终消息          if (jsonData.success && jsonData.data) {            // 更新全局聊天列表            setChats(prevChats =>              prevChats.map(chat => {                if (chat._id === selectedChat?._id) {                  const updatedMessages = [...chat.messages, jsonData.data]                  const firstAssistantMessage =                    updatedMessages.find(item => item.role === 'assistant')                      ?.content || ''                  const shouldUpdateName =                    chat.name === '新聊天' ||                    chat.name === '未命名聊天' ||                    !chat.name                  const newName = shouldUpdateName                    ? firstAssistantMessage.substring(0, 12)                    : chat.name                  if (shouldUpdateName) {                    fetch('/api/chat/rename', {                      method: 'POST',                      headers: {                        'Content-Type': 'application/json',                      },                      body: JSON.stringify({                        chatId: chat._id,                        name: newName,                      }),                    }).catch(err => {                      console.error('自动命名入库失败', err)                    })                  }                  return {                    ...chat,                    name: newName,                    messages: updatedMessages,                  }                }                return chat              })            )          }        }      }    } catch (error) {      toast.error(        error instanceof Error ? error.message : '发送消息失败,请重试'      )      setPrompt(promptCopy) // 恢复输入内容    } finally {      setIsLoading(false)    }  }    return (    <form      onSubmit={sendPrompt}      className={`w-full ${        selectedChat?.messages.length ? 'max-w-3xl' : 'max-w-2xl'      } bg-[#404045] p-4 rounded-3xl mt-4 transition-all`}    >      <textarea        onKeyDown={handleKeyDown}        ref={textareaRef}        className="outline-none w-full resize-none overflow-y-auto break-words bg-transparent max-h-[336px] text-white"        rows={2}        placeholder="给 DeepSeek 发送消息"        required        onChange={e => setPrompt(e.target.value)}        value={prompt}      />      ...

这里我们DOM也改了一下,form添加了onSubmit方法,textarea添加了onKeyDown方法,这时候我们在文本框输入,看下/ai 接口是有返回的

我们去把对话显示出来,我们把page.tsx里messages那里改下

const { selectedChat } = useAppContext() // 从全局状态中获取selectedChatconst containerRef = useRef<HTMLDivElement>(null)  useEffect(() => {    if (selectedChat) {      setMessages(selectedChat.messages)    }  }, [selectedChat])  // 切换对话的时候 可以到每段对话的结束  useEffect(() => {    if (containerRef.current) {      containerRef.current?.scrollTo({        top: containerRef.current.scrollHeight,        behavior: 'smooth',      })    }  })  ...{messages.length === 0 ? (    <>     ...  ) : (    <div      ref={containerRef}      className="relative flex flex-col items-center justify-start w-full mt-20 max-h-screen    overflow-y-auto"    >      <p        className="fixed top-8 border border-transparent hover:border-gray-500/50 py-1      px-2 rounded-lg font-semibold mb-6"      >        {selectedChat?.name}      </p>      {messages.map((msg, index) => (        <Message key={index} role={msg.role} content={msg.content} />      ))}      {isLoading && (        <div className="flex gap-4 max-w-3xl w-full py-3">          <Image            className="h-9 w-9 p-1 border border-white/15 rounded-full"            src={assets.logo_icon}            alt="Logo"          />          <div className="loader flex justify-center items-center gap-1">            <div className="w-1 h-1 rounded-full bg-white animate-bounce"></div>            <div className="w-1 h-1 rounded-full bg-white animate-bounce"></div>            <div className="w-1 h-1 rounded-full bg-white animate-bounce"></div>          </div>        </div>      )}    </div>  )}

显示就是渲染返回的数组了messages, 我们看下页面,返回的是markdown形式,我们要处理下

渲染markdown形式

前面我们已经安装了react-markdown, 我们用这个渲染,来到Message组件, 把要渲染的content用Markdown包裹起来

import Markdown from 'react-markdown'<div className="space-y-4 w-full overflow-scroll">    <Markdown>{content}</Markdown></div>

变成这样了,样式比较美观了

代码格式渲染

我们试下代码,可以看到代码不是代码格式

前面我们安装了prismjs, 这个是语法高亮的, 要安装下pnpm i @types/prismjs -D

import Prism from 'prismjs'const Message = ({ role, content }: MessageProps) => {  useEffect(() => {    Prism.highlightAll()  }, [content])  ...

然后要加prism.css, 在src/app文件夹下建立prism.css,

pre[class*='language-'],code[class*='language-'] {  color: #d4d4d4;  font-size: 13px;  text-shadow: none;  font-family: Menlo, Monaco, Consolas, 'Andale Mono', 'Ubuntu Mono',    'Courier New', monospace;  direction: ltr;  text-align: left;  white-space: pre;  word-spacing: normal;  word-break: normal;  line-height: 1.5;  -moz-tab-size: 4;  -o-tab-size: 4;  tab-size: 4;  -webkit-hyphens: none;  -moz-hyphens: none;  -ms-hyphens: none;  hyphens: none;}pre[class*='language-']::selection,code[class*='language-']::selection,pre[class*='language-'] *::selection,code[class*='language-'] *::selection {  text-shadow: none;  background: #264f78;}@media print {  pre[class*='language-'],  code[class*='language-'] {    text-shadow: none;  }}pre[class*='language-'] {  padding: 1em;  margin: 0.5em 0;  overflow: auto;  background: #1e1e1e;}:not(pre) > code[class*='language-'] {  padding: 0.1em 0.3em;  border-radius: 0.3em;  color: #db4c69;  background: #1e1e1e;}/********************************************************** Tokens*/.namespace {  opacity: 0.7;}.token.doctype .token.doctype-tag {  color: #569cd6;}.token.doctype .token.name {  color: #9cdcfe;}.token.comment,.token.prolog {  color: #6a9955;}.token.punctuation,.language-html .language-css .token.punctuation,.language-html .language-javascript .token.punctuation {  color: #d4d4d4;}.token.property,.token.tag,.token.boolean,.token.number,.token.constant,.token.symbol,.token.inserted,.token.unit {  color: #b5cea8;}.token.selector,.token.attr-name,.token.string,.token.char,.token.builtin,.token.deleted {  color: #ce9178;}.language-css .token.string.url {  text-decoration: underline;}.token.operator,.token.entity {  color: #d4d4d4;}.token.operator.arrow {  color: #569cd6;}.token.atrule {  color: #ce9178;}.token.atrule .token.rule {  color: #c586c0;}.token.atrule .token.url {  color: #9cdcfe;}.token.atrule .token.url .token.function {  color: #dcdcaa;}.token.atrule .token.url .token.punctuation {  color: #d4d4d4;}.token.keyword {  color: #569cd6;}.token.keyword.module,.token.keyword.control-flow {  color: #c586c0;}.token.function,.token.function .token.maybe-class-name {  color: #dcdcaa;}.token.regex {  color: #d16969;}.token.important {  color: #569cd6;}.token.italic {  font-style: italic;}.token.constant {  color: #9cdcfe;}.token.class-name,.token.maybe-class-name {  color: #4ec9b0;}.token.console {  color: #9cdcfe;}.token.parameter {  color: #9cdcfe;}.token.interpolation {  color: #9cdcfe;}.token.punctuation.interpolation-punctuation {  color: #569cd6;}.token.boolean {  color: #569cd6;}.token.property,.token.variable,.token.imports .token.maybe-class-name,.token.exports .token.maybe-class-name {  color: #9cdcfe;}.token.selector {  color: #d7ba7d;}.token.escape {  color: #d7ba7d;}.token.tag {  color: #569cd6;}.token.tag .token.punctuation {  color: #808080;}.token.cdata {  color: #808080;}.token.attr-name {  color: #9cdcfe;}.token.attr-value,.token.attr-value .token.punctuation {  color: #ce9178;}.token.attr-value .token.punctuation.attr-equals {  color: #d4d4d4;}.token.entity {  color: #569cd6;}.token.namespace {  color: #4ec9b0;}/********************************************************** Language Specific*/pre[class*='language-javascript'],code[class*='language-javascript'],pre[class*='language-jsx'],code[class*='language-jsx'],pre[class*='language-typescript'],code[class*='language-typescript'],pre[class*='language-tsx'],code[class*='language-tsx'] {  color: #9cdcfe;}pre[class*='language-css'],code[class*='language-css'] {  color: #ce9178;}pre[class*='language-html'],code[class*='language-html'] {  color: #d4d4d4;}.language-regex .token.anchor {  color: #dcdcaa;}.language-html .token.punctuation {  color: #808080;}/********************************************************** Line highlighting*/pre[class*='language-'] > code[class*='language-'] {  position: relative;  z-index: 1;}.line-highlight.line-highlight {  background: #f7ebc6;  box-shadow: inset 5px 0 0 #f7d87c;  z-index: 0;}

我的这个css文件来自prism-themes,选一个你喜欢的,把代码复制过去就行

layout.tsx要引入这个文件

import './prism.css'

这样代码就能和文本分离了

调用对话相关事件

我们现在的对话事件,是取列表的时候自动创建的;
我们需要点击对话气泡图标的时候,调用对话创建接口

const Sidebar: React.FC<SidebarProps> = ({ expand, setExpand }) => {  const { openSignIn } = useClerk()  const { user, chats, createNewChat, fetchUsersChats, selectedChat } =    useAppContext()  const [openMenu, setOpenMenu] = useState<OpenMenuState>({    id: null,    open: false,  })  // 创建新聊天后 聊天记录也要是新聊天的 就是说新聊天聊天记录为空  const createNew = async () => {    await createNewChat()    fetchUsersChats()  }  ...

然后我们对话有重命名、删除、根据AI 返回的内容自动重命名,我们把这些加进来ChatLabel.tsx

import React, { Dispatch, SetStateAction } from 'react'import { assets } from '@/assets/assets'import Image from 'next/image'import { useAppContext } from '@/context/AppContext'import axios from 'axios'import { OpenMenuState } from '@/types'import { toast } from 'react-hot-toast'// 使用接口定义props// 允许 id 为 string 或 null(null 表示无菜单打开)// 定义组件 Props 类型interface ChatLabelProps { openMenu: OpenMenuState setOpenMenu?: Dispatch<SetStateAction<OpenMenuState>> // 接收完整状态对象 id: string // 聊天ID name: string // 聊天名称 isSelected?: boolean // 是否为当前选中的聊天}const ChatLabel: React.FC<ChatLabelProps> = ({ openMenu, setOpenMenu, id, // 聊天ID(从父组件传入) name, // 聊天名称(从父组件传入) isSelected = false, // 是否为当前选中的聊天(默认为false)}) => { // 从全局状态获取方法和数据 const { fetchUsersChats, chats, setSelectedChat } = useAppContext() const selectChat = () => {   const chatData = chats.find(chat => chat._id == id) || null   if (!chatData) return   setSelectedChat(chatData) } const renameHandler = async () => {   try {     const newName = prompt('请输入新的聊天名称')     if (!newName) return     const { data } = await axios.post('/api/chat/rename', {       chatId: id,       name: newName,     })     if (data.success) {       fetchUsersChats()       setOpenMenu?.({ id: null, open: false })       toast.success(data.message)     } else {       toast.error(data.message)     }   } catch (error) {     const message = error instanceof Error ? error.message : '重命名失败'     toast.error(message)   } } const deleteHandler = async () => {   try {     const confirm = window.confirm('确定要删除此聊天吗?')     if (!confirm) return     const { data } = await axios.post('/api/chat/delete', {       chatId: id,     })     if (data.success) {       fetchUsersChats()       setOpenMenu?.({ id: null, open: false })       toast.success(data.message)     } else {       toast.error(data.message)     }   } catch (error) {     const message = error instanceof Error ? error.message : '删除消息失败'     toast.error(message)   } } const wrapperClick = (e: React.MouseEvent, id: string) => {   e.stopPropagation() // 阻止事件冒泡,避免触发 selectChat   setOpenMenu?.({ id, open: !openMenu.open }) // 切换菜单状态 } return (   <div     onClick={selectChat}     className={`flex items-center justify-between p-2 text-white/80       hover:bg-white/10        ${isSelected ? 'bg-white/10' : ''} // 选中时应用与 hover 相同的背景       rounded-lg text-sm group cursor-pointer`}   >     <p className="group-hover:max-w-5/6 truncate">{name}</p>     <div       onClick={e => wrapperClick(e, id)}       className="group relative flex items-center justify-center h-6 w-6 aspect-square     hover:bg-black/80 rounded-lg"     >       <Image         src={assets.three_dots}         alt=""         className={`w-4 ${           openMenu.id === id && openMenu.open ? '' : 'hidden'         } group-hover:block`}       />       <div         className={`absolute ${           openMenu.id === id && openMenu.open ? 'block' : 'hidden'         } -right-32 top-6 bg-gray-700 rounded-xl w-max p-2`}       >         <div           onClick={renameHandler}           className="flex items-center gap-3 hover:bg-white/10 px-3 py-2 rounded-lg"         >           <Image src={assets.pencil_icon} alt="" className="w-4" />           <p>重命名</p>         </div>         <div           className="flex items-center gap-3 hover:bg-white/10 px-3 py-2 rounded-lg"           onClick={deleteHandler}         >           <Image src={assets.delete_icon} alt="" className="w-4" />           <p>删除</p>         </div>       </div>     </div>   </div> )}export default ChatLabel

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

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

最后小结

还有一些逻辑 可以完善的

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

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

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

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

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

DeepSeek Next.js AI应用 Web开发 Clerk
相关文章