使用 nextjs 构建 ai 对话页面

578 阅读5分钟

image.png

本文的目标是使用 nextjs 构建一个 ai 对话页面,默认已经有一个 nextjs 项目了

猛击进入 ai 对话页面

猛击直达 github 仓库

选择一个 AI

昨天逛掘金的时候发现了一个对比各家 ai 收费的网站 猛击访问

image.png

可以看到智谱 AI 的 GLM-4-Flash 模型是免费的,好用吗?一般般吧!但是我们自己玩一玩已经足够了

目前市面上大多数 AI 模型的调用都兼容 openAI 对接完成后换成其他模型也很方便

也可以使用 deepseek 的 AI 四舍五入算是不要钱!能力也不错

image.png

或者选择阿里系模型价格也很便宜 猛击直达

image.png

这里我们使用智谱 AI 的 GLM-4-Flash 模型

首先需要注册猛击直达,进入控制台

image.png

点击 API 密钥创建一个密钥

image.png

AI 调用

这里我们使用 vercel 的 ai sdk 它对 AI 调用做了一些封装猛击直达 极大的简化了调用的过程,与之类似的还有 langchain

写到这里发现智谱有自己的 sdk 但是我不用!因为如果换一个 AI 就需要重新对接但是 vercel 的 ai sdk 和 langchain 则不需要

执行 pnpm add ai @ai-sdk/openai 安装需要的依赖

然后根据路径 app/api/ai/chat/route.ts 创建对应的文件, nextjs 的 api 接口需要写在 api 目录下的 route.ts 中否则无法识别

写入如下内容

import { CoreMessage, streamText } from 'ai' // 导入AI相关的核心类型和流式文本处理函数
import { createOpenAI } from '@ai-sdk/openai' // 导入创建OpenAI客户端的函数

// POST请求处理函数
export async function POST(req: Request) {
  // 从请求体中解构获取消息数组
  const { messages }: { messages: CoreMessage[] } = await req.json()

  // 创建智谱AI的客户端实例
  const openai = createOpenAI({
    baseURL: 'https://open.bigmodel.cn/api/paas/v4/', // 智谱AI的API基础URL
    compatibility: 'strict', // 设置兼容模式为严格模式
    apiKey: process.env.ZHI_PU_AI_API_KEY // 从环境变量获取API密钥
  })

  // 使用streamText函数创建流式文本响应
  const result = await streamText({
    model: openai('glm-4-flash'), // 使用智谱AI的GLM-4-Flash模型
    system: '你是一名优秀的开发工程师', // 设置AI助手的系统角色提示
    messages // 传入用户消息历史
  })

  // 将结果转换为数据流响应并返回
  return result.toDataStreamResponse()
}

是不是很简单但是需要明白的是,简单是因为 vercel 的 ai sdk 帮我们做了很多事情!

调用下接口看看

image.png

因为我们设置的是流失返回所以 AI 的回复并不是一次性返回的

我们把代码修改成等 AI 生成完再返回,再试一下

import { CoreMessage, generateText } from 'ai' // 导入AI相关的核心类型和流式文本处理函数
import { createOpenAI } from '@ai-sdk/openai' // 导入创建OpenAI客户端的函数

// POST请求处理函数
export async function POST(req: Request) {
  // 从请求体中解构获取消息数组
  const { messages }: { messages: CoreMessage[] } = await req.json()

  // 创建智谱AI的客户端实例
  const openai = createOpenAI({
    baseURL: 'https://open.bigmodel.cn/api/paas/v4/', // 智谱AI的API基础URL
    compatibility: 'strict', // 设置兼容模式为严格模式
    apiKey: process.env.ZHI_PU_AI_API_KEY // 从环境变量获取API密钥
  })

  // 主要修改的部分 =============================
  const { text } = await generateText({
    model: openai('glm-4-flash'),
    messages: [{ role: 'system', content: '你是一名优秀的开发工程师' }, ...messages]
  })

  // 返回JSON响应
  return Response.json({
    content: text,
    role: 'assistant'
  })
}

使用两个 gif 来对比下和流式返回的区别

iShot_2024-10-30_17.33.51.gif

流式返回

iShot_2024-10-30_17.36.05.gif

结果都是一样的,不同的是流式返回可以很快的响应进行输入,非流式则需要等 AI 生成完毕后一次返回需要等待一些时间

具体选择哪一个则需要具体场景具体分析,目前大部分的 AI 应用目前都是流式返回

对话页面

上边接口我们已经搞定了,那么下边看下页面如何构建

根据路径 app/aiChat/page.tsx 创建对应的文件,写入如下内容

'use client'

import { useState, useRef, useEffect } from 'react'
import { useChat, Message } from 'ai/react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Card } from '@/components/ui/card'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Loader2, Send, StopCircle, User } from 'lucide-react'
import { BytemdViewer } from '@/components/bytemd/viewer'
import { Icon } from '@iconify/react'
import Link from 'next/link'

function PageHeader() {
  return (
    <div className="mb-4 flex items-center justify-between">
      <h1 className="text-2xl font-bold">
        {process.env.NEXT_PUBLIC_GITHUB_USER_NAME} blog AI 小助手
      </h1>

      <Link href="/">
        <Button size="icon">
          <Icon icon="lets-icons:refund-back" width="20px" />
        </Button>
      </Link>
    </div>
  )
}

function UserMessage({ message }: { message: Message }) {
  return (
    <>
      <Card className="max-w-[80%] p-2">{message.content ?? ''}</Card>

      <Avatar>
        <AvatarFallback>
          <User className="h-4 w-4" />
        </AvatarFallback>
        <AvatarImage src="/user-avatar.png" alt="User Avatar" />
      </Avatar>
    </>
  )
}

function AssistantMessage({ message }: { message: Message }) {
  return (
    <>
      <Avatar>
        <AvatarFallback>AI</AvatarFallback>
        <AvatarImage src="/ai-avatar.png" alt="AI Avatar" />
      </Avatar>

      <Card className="max-w-[80%] p-2">
        <BytemdViewer content={message.content ?? ''}></BytemdViewer>
      </Card>
    </>
  )
}

function MessageInfo({ message }: { message: Message }) {
  return (
    <div
      key={message.id}
      className={`flex items-start space-x-2 mb-4 ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
    >
      {message.role === 'assistant' ? (
        <AssistantMessage message={message}></AssistantMessage>
      ) : (
        <UserMessage message={message}></UserMessage>
      )}
    </div>
  )
}

function MessageList({ messages }: { messages: Message[] }) {
  return messages.map((message) => <MessageInfo key={message.id} message={message}></MessageInfo>)
}

export default function AIChatPage() {
  const { messages, input, handleInputChange, handleSubmit, isLoading, stop } = useChat({
    api: '/api/ai/chat',
    keepLastMessageOnError: true
  })

  const [chatStarted, setChatStarted] = useState(false)
  const scrollAreaRef = useRef<HTMLDivElement>(null)
  const [scrollHeight, setScrollHeight] = useState(0)

  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    if (input.trim()) {
      handleSubmit(e)
      if (!chatStarted) setChatStarted(true)
    }
  }

  useEffect(() => {
    const scrollElement = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]')
    if (scrollElement) {
      const observer = new ResizeObserver((entries) => {
        for (let entry of entries) {
          if (entry.target === scrollElement) {
            setScrollHeight(entry.target.scrollHeight)
            entry.target.scrollTop = entry.target.scrollHeight
          }
        }
      })

      observer.observe(scrollElement)

      return () => {
        observer.disconnect()
      }
    }
  }, [messages])

  return (
    <div className="flex flex-col h-screen w-full mx-auto p-4 md:w-10/12">
      <PageHeader></PageHeader>

      <Card className="flex-grow mb-4 overflow-hidden">
        <ScrollArea
          className="h-[calc(100vh-160px)]"
          ref={scrollAreaRef}
          style={{ height: `calc(100vh - 160px)`, maxHeight: `calc(100vh - 160px)` }}
        >
          <div className="p-4" style={{ minHeight: `${scrollHeight}px` }}>
            {!chatStarted && (
              <div className="text-center text-gray-500 mt-8">开始与 AI 助手对话</div>
            )}

            <MessageList messages={messages}></MessageList>

            {isLoading && (
              <div className="flex justify-center items-center mt-4">
                <Loader2 className="h-6 w-6 animate-spin" />
              </div>
            )}
          </div>
        </ScrollArea>
      </Card>
      <form onSubmit={onSubmit} className="flex space-x-2">
        <Input
          value={input}
          onChange={handleInputChange}
          placeholder="在此处输入您的消息..."
          disabled={isLoading}
          className="flex-grow"
        />
        <Button type="submit" disabled={isLoading || !input.trim()}>
          <Send className="h-4 w-4 mr-2" />
          Send
        </Button>
        {isLoading && (
          <Button type="button" variant="outline" onClick={() => stop()}>
            <StopCircle className="h-4 w-4 mr-2" />
            Stop
          </Button>
        )}
      </form>
    </div>
  )
}

整体代码不算多,我们来分析一下

image.png

其中 AssistantMessage 使用了封装的 bytemd 组件来展示 AI 的回复,因为 AI 的回复是 md 格式的。

AIChatPage 这里同样使用 vercel 的 ai sdk

image.png

指定我们上边写的 api 接口路径

image.png

使用

image.png

然后有人可能对这一段代码有些疑问

image.png

他的作用是监听消息列表变化,自动滚动到底部

iShot_2024-10-30_18.13.46.gif

总结

本文实践了如何用 Next.js 和 Vercel AI SDK 搭建一个简单的对话式 AI 页面。

我们选择了智谱 AI 的 GLM-4-Flash 模型(免费)并通过 Vercel AI SDK 简化 AI 模型的调用过程。

文章会带你一步步完成 API 配置、Next.js 后端设置和对话页面的搭建,让你轻松实现一个 AI 对话页面。

往期文章

答疑

使用 vercel 部署 api 10s 左右就会自动停止

image.png

这是因为 vercel 默认最大持续时间是 10s 有多种办法可以解决 猛击直达 vercel 文档

全局设置最大持续时间为 60s

image.png

image.png

单个 api 最大持续时间

image.png

通过 vercel.json

image.png

设置成 edge 边缘函数 (未测试理论可行)

image.png

image.png