基于AI大模型业务的前端最佳解决方案

1,109 阅读16分钟

目前很多公司的业务,都开始加入AI大模型板块,作为前端,第一次碰到这种情况的你,该如何解决?

此时,如果公司内部有自研大模型,那么从前端角度来讲,你只需调用传参即可,调用方式可以采用websocket、SSE等方式,具体要和算法部门后端商议,另外你还要考虑到“流式传输显示”等一系列复杂细节问题。

反之,如果公司没有自研模型,由于项目需要,那么可能会临时内嵌一个外围大模型,用以做对话、助手等功能。

部分人第一次见的话,难免会感到“头疼”,也有点无从下手,实际上只要你了解其原理,那么就很简单了。

把我个人的最佳解决方案记录下来,代码、方法通通放在下面,咱们一步步拆解,看完后,相信你对于接下来的类似业务,定会手到擒来,宝子们可以放心食用!

拜托拜托:您的点赞收藏,是我更新的最大动力,如果能帮助到大家,我就很开森啦,祝好!

微信图片_20241223092704.jpg

一.AI大模型业务的具体调用方法

由于本周参与的项目,目前还未结项,所以为了能100%原汁原味,给大家演示+复盘,晚上做了个1:1复刻demo,力争等比还原!话不多说,咱们开始正题!

前端对接大模型,你要考虑的点,其实就是接口+数据流显示+页面设计的问题,通常情况下,用websocket是不错的选择,而且多数公司,也都会用它来传输、支持各条业务线

先上图让大家感受一下:(具体AI模型的页面样式设计,看UI看项目,意会即可)

image.png

image.png

往往这种AI模块,是通过“第三方引入的方式”,从而实现出来的,解决方案有两个比较好,一个是iframe,算是通用的老手解决法,还有一种就是通过“插件”的形式,引入到项目中

iframe解决方案往上有很多,就不多赘述啦。重点讲一下“插件”引入的实现过程。

名字听着比较唬人,其实就是把你这个“AI模块”,单独起一个项目,然后打包的时候,通过webpack或vite,将项目打包成js文件,最后在主项目中,通过createElement创建script标签,将其挂载即可,这其中要注意挂载+销毁时机的问题。

引入到主项目后,AI大模型插件基本会当作挂件,挂在页面上(如下图):

image.png

对于大模型的调用方式,其实方法大同小异,基本类似,为保证接口顺畅,这里我拿月之暗面和gpt这两个,给大家做个演示,就很清晰了。

如果你是用市面上的大模型去做、去对接,那么要按照人家官方文档的方式进行套用,比如:

月之暗面官方文档:这里这里!

GPT接口使用文档:platform(需要登录梯梯)

对接外部AI模型,通常情况下,需要申请账号,并且拥有一个api-key,多内外各厂商都会给这个密钥,设置免费初始额度,让用户来体验+使用,当然,如果额度超出后,想继续使用,那么就要付费啦。

image.png

对于各模型的用量限制,你在它的官方文档上,都会找到:

image.png

比如月之暗面,会给用户15元的免费额度,当天使用,隔天就可以查询剩余用量:

image.png

反之,如果公司有自研大模型的话,那么你不需要输入api-key,只需要传后端要的参数,并且把你每次的问题及答案,都放在一个数组即可。

这么做的意义,也是为了能完成上下文连贯做答。(上一次,上上次等的回答记录,都要积累加在这个数组,然后再给后端返回去)

这里做法有两种,前端去做,后端去做都行,所以到时你们需要商量好。

举个例子,比如这里后端去做的话,首先定义一个空数组变量:

image.png

然后后端每次在接收到前端消息时,都会将对应的内容,存入在空数组中,如下:

image.png

国内外大模型的传参方式,大多类似,一次对话记录,都会有role(角色)+content(内容)两个参数

其中role字段,有三种角色,前端作为问题的发起者、使用者,那么角色就是“user"。

后端调用大模型接口,然后把内容返回给前端,那么它可以视为“回答者”,role角色就是“assistant”

第三个角色通常是大模型预留给用户的参数(用于调参+训练模型),比如市面上多种角色,比如女友AI,数学AI、体育AI等等,都是通过参数来调整实现的,这个角色role可以是“system”。

image.png

当然,这里我以月之暗面为例,其余AI模型同理。

所以到了这里,我给大家做个前端的传参演示,你就懂了,我问大模型两个问题,然后你看看它是如何完成上下文衔接的?

刚开始,我问他“写一段定时器代码给我”:

image.png

你可以在控制台进行调试,第一步websocket先进行连接,并且发ping包。

image.png

随后按照你们后端要求的传参格式,进行传参,比如这里我有两个参数,一个是event,和后端约定俗成的字段,第二个content就是你的问题,随后你会收到流式数据。

image.png

那么与此同时,后端这边做了什么?其实就是拿到前端的参数,然后把参数按照“AI大模型官方接口文档”的形式,去请求接口,再把数据拿到,返回给前端。

所以你可以看到role角色字段的变化,user是问题发起人,assiatant是回答者。

image.png

接着你问了第二个问题,为了让AI大模型上下文保持连贯,后端把前端的参数,拼接在了同一个数组中,将之全部传给大模型官方api,于是,连贯的上下文就此诞生!

image.png

我把这个数组以代码形式放在下面,你只需要看我的注释部分,即可完全理解“上下文的含义”。

this.streamMessages [

    1.第一次问问题*****************************************************************
  { role: 'user', content: '写一段定时器代码\n' },
  
    2.AI第一次回答*****************************************************************
  {
    role: 'assistant',
    content: '当然可以。以下是使用 Python 的 `threading` 模块实现的一个简单的定时器代码示例:\n' +
      '\n' +
      '```python\n' +
      'import threading\n' +
      'import time\n' +
      '\n' +
      'def timer(seconds, callback):\n' +
      '    def timer_thread():\n' +
      '        time.sleep(seconds)\n' +
      '        callback()\n' +
      '    threading.Thread(target=timer_thread).start()\n' +
      '\n' +
      'def my_callback():\n' +
      '    print("定时器时间到!")\n' +
      '\n' +
      '# 设置定时器,5秒后执行 my_callback 函数\n' +
      'timer(5, my_callback)\n' +
      'print("定时器已启动,5秒后将执行回调函数。")\n' +
      '\n' +
      '# 保持主线程运行,否则程序会立即退出\n' +
      'while True:\n' +
      '    time.sleep(1)\n' +
      '```\n' +
      '\n' +
      '这段代码定义了一个 `timer` 函数,它接受两个参数:`seconds`(定时器的秒数)和 `callback`(定时器时间到后要执行的回调函数
)。`timer` 函数内部定义了一个 `timer_thread` 函数,该函数在一个新的线程中执行,等待指定的秒数后调用回调函数。\n' +
      '\n' +
      '`my_callback` 函数是回调函数的示例,当定时器时间到时,它会打印一条消息。\n' +
      '\n' +
      '最后,我们使用 `timer` 函数设置了一个5秒的定时器,并启动了主线程的无限循环,以保持程序运行,直到定时器触发回调函数。\n' +
      '\n' +
      '请注意,这个示例代码在实际应用中可能需要根据具体需求进行调整。例如,你可能需要处理异常、添加取消定时器的功能或者在多线 
程环境中同步回调函数的执行。'
  },
  
  3.前端第二次问问题,用以测试AI的上下文连贯度************************************************
  { role: 'user', content: '解释一下\n' },
  
  4.AI第二次的回答,事实证明,确实实现了上下文连贯********************************************
  {
    role: 'assistant',
    content: '当然,我来解释一下这段代码的工作原理:\n' +
      '\n' +
      '1. **导入模块**:\n' +
      '   ```python\n' +
      '   import threading\n' +
      '   import time\n' +
      '   ```\n' +
      '   这里我们导入了两个Python标准库模块:`threading` 和 `time`。`threading` 模块允许我们创建和管理线程,而 `time` 模块提 
供了各种与时间相关的函数。\n' +
      '\n' +
      '2. **定义定时器函数**:\n' +
      '   ```python\n' +
      '   def timer(seconds, callback):\n' +
      '       def timer_thread():\n' +
      '           time.sleep(seconds)\n' +
      '           callback()\n' +
      '       threading.Thread(target=timer_thread).start()\n' +
      '   ```\n' +
      '   - `timer` 函数接受两个参数:`seconds`(等待的秒数)和 `callback`(当等待时间结束后要调用的函数)。\n' +
      '   - 在 `timer` 函数内部,我们定义了另一个函数 `timer_thread`,这是一个内部函数,用于在新线程中执行。\n' +
      '   - `timer_thread` 函数使用 `time.sleep(seconds)` 使当前线程暂停指定的秒数。\n' +
      '   - 等待结束后,`timer_thread` 调用传入的 `callback` 函数。\n' +
      '   - `threading.Thread(target=timer_thread).start()` 创建一个新的线程,并指定 `timer_thread` 作为目标函数,然后启动这个
线程。\n' +
      '\n' +
      '3. **定义回调函数**:\n' +
      '   ```python\n' +
      '   def my_callback():\n' +
      '       print("定时器时间到!")\n' +
      '   ```\n' +
      '   - 这是一个简单的函数,当定时器触发时,它会打印一条消息。\n' +
      '\n' +
      '4. **设置定时器并启动**:\n' +
      '   ```python\n' +
      '   timer(5, my_callback)\n' +
      '   print("定时器已启动,5秒后将执行回调函数。")\n' +
      '   ```\n' +
      '   - 这里我们调用 `timer` 函数,设置等待时间为5秒,并指定 `my_callback` 作为回调函数。\n' +
      '   - 打印一条消息,告知用户定时器已启动。\n' +
      '\n' +
      '5. **保持主线程运行**:\n' +
      '   ```python\n' +
      '   while True:\n' +
      '       time.sleep(1)\n' +
      '   ```\n' +
      '   - 这是一个无限循环,使用 `time.sleep(1)` 每次暂停1秒。\n' +
      '   - 这个循环的目的是保持主线程活跃,否则程序会立即退出,导致定时器线程无法执行。\n' +
      '\n' +
      '这段代码的关键在于使用线程来处理定时任务,这样主线程可以继续执行其他任务,而不会阻塞程序的运行。通过这种方式,我们可以 
在不干扰主程序流程的情况下执行定时任务。'
  }
]

总结一下:

如果公司没有自研大模型,要用外部的,那么api-key是后端去搞得,他会按照AI模型的官方文档,拿到密钥,并且按照人家官方的格式,去调用大模型接口。

后端的作用类似于中转站,他把结果最后会返回给你,所以前端这时要做的就是websocket接收及数据处理就够了。

下面我把整个demo的代码全部奉上,后端基于nestjs,前端基于vue3+ts,对于一些想学后端的前端宝子,亦或是对于即将上手AI模型业务的前端人,会有很大帮助!(完整前后端项目,我会放在github,大家随取随用,随时参考!)

1.前后端对接月之暗面模型(Nestjs+vue3)

为了将对接讲的更清楚,这里我用nestjs先对接月之暗面接口,然后给前端提供模型支持。

后端nestjs这一块,最重要的是websocket部分,原理是先请求月之暗面接口,然后用websocket,将数据返还给前端。

前端其实就是websocket接收,做一个流式显示即可。

nestjs实现部分:

第一步通过node全局安装nestjs脚手架(Nodejs版本>=16)

npm i -g @nestjs/cli

第二步通过命令行创建项目,并命名

nest new project-name

第三步安装websocket和openai(因为月之暗面使用了openai服务)

@nestjs/websockets、ws、openai
这三个安装方式,均为npm/pnpm install即可

第四步,新建文件,命名为ws.gateway.ts,并加入如下代码(里面我都写了注释,可供参考)

// 1.导入各种依赖
import {
  WebSocketGateway,
  WebSocketServer,
  SubscribeMessage,
  MessageBody,
  OnGatewayConnection,
  OnGatewayDisconnect,
} from '@nestjs/websockets';

import { OpenAI } from 'openai'; // 确保已经安装 openai 包
import { Server, WebSocket } from 'ws';

// 2.启动websocket服务,并且这里将端口改为了3004
@WebSocketGateway(3004)
export class WsStartGateway
  implements OnGatewayConnection, OnGatewayDisconnect {
  @WebSocketServer()
  server: Server;

  // 3.定义全局变量和openaiClient
  private readonly openaiClient: OpenAI;
  private sessions = [];
  private streamMessages = [];
  constructor() {
    // 4.初始化openaiClien,配置你的月之暗面API Key+API基础路径
    this.openaiClient = new OpenAI({
      apiKey: "*************", // 替换为你的 API Key
      baseURL: "https://api.moonshot.cn/v1", // Moonshot API 的基础路径
    });
  }

  
  handleDisconnect(client: any) {
    console.log('mmmmmllllllll', client);
  }
  handleConnection(client: any, ...args: any[]) {
    console.log('mmmmm', client);
    // 分配唯一会话 ID(以 WebSocket 对象为标识)
    const sessionId = client.id;

    // 初始化当前会话的历史记录
    this.sessions[sessionId] = [];
    console.log(`WebSocket client connected: ${sessionId}`);
  }

  // 5.定义消息处理函数
  @SubscribeMessage('message')
  async handleMessage(client: WebSocket, message: { content: string }) {
    const sessionId = client.id;
    console.log('Received message:', message); // 打印原始消息
    try {
      // 检查消息是否存在 content 属性
      if (!message || !message.content) {
        throw new Error('No content found in the message');
      }
      const content = message.content; // 获取用户输入的内容
      console.log('Parsed content:', content); // 打印解析后的内容
      this.streamMessages.push({
        role: "user", content: content
      })
      console.log('用户发送完之后', this.sessions)
      // 调用 Moonshot AI 的聊天接口
      const completion = await this.openaiClient.chat.completions.create({
        model: "moonshot-v1-auto",
        messages: this.streamMessages,
        temperature: 0.3, // 控制回答的随机性
        stream: true
      });
      client.send(JSON.stringify(JSON.stringify({ reply: "", isStreaming: true })))
      let fullReply = ""; // 用于记录完整回复
      for await (const chunk of completion) {
        const delta = chunk.choices[0]?.delta;
        if (delta?.content) {
          client.send(JSON.stringify({ reply: delta.content }));
          fullReply += delta.content;
        }
      }

      this.streamMessages.push({ role: "assistant", content: fullReply });
      console.log('kimi回答之后的内容', fullReply)

      if (fullReply) {
        console.log('fullReply', fullReply);
      }

    } catch (error) {
      console.error('Error:', error); // 打印错误信息
      client.send(JSON.stringify({ reply: "Kimi 暂时无法回答您的问题,请稍后再试。" }));
    }

  }
}

第五步,在app.module.ts引入依赖

image.png

只看我画箭头的地方即可,多的删除,缺的补上即可,因为有些内容是我其它的业务代码

import { Module } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { WsStartGateway } from './ws.gateway';
import { OpenaiService } from './OpenAi/openai.service';
@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService, PrismaClient, WsStartGateway, OpenaiService],
})
export class AppModule { }

最后启动命令,你的后端服务就写好了:

npm run start:dev

image.png

vue3前端实现部分:

首先前端demo我全写在这个文件

image.png

代码如下,由于过长,所以你可以直接看注释

<template>
  <div class="chat-container">
    <div class="top">
      <div>JJAI Chat</div>
    </div>
    <div class="chatBox" v-if="!messages.length">
      <div class="boxImg"><img src="@/assets/logo.png" class="robotImg" /></div>
    </div>
    <!-- 中部内容区 -->
    <div class="chatInfor" v-if="!messages.length">
      <div class="chatContent">
        JJChat is an artificial-intelligence catbot developed by JJAI
      </div>
      <div class="chatContent">JJAI launched in Noveber 2024</div>
      <div class="chatContent">
        JJChat is an artificial-intelligence Chatbot developed by JJAI
      </div>
      <div class="description">This is example that what can I do for you</div>
    </div>
    <div v-else>
      <!-- 显示聊天记录 -->
      <div class="boxHeight">
        <div class="chat-box">
          <div class="messages">
            <div
              v-for="(message, index) in messages"
              :key="index"
              class="message-wrapper"
              :class="message.role === 'user' ? 'user-message' : 'ai-message'"
            >
              <div
                class="message"
                v-html="renderMessageContent(message.content)"
              ></div>
            </div>
          </div>
        </div>
      </div>
    </div>

    <div class="bottom">
      <div class="search">
        <div class="input-box">
          <textarea
            class="textVal"
            v-model="userInput"
            placeholder="请输入内容开始聊天"
            @keyup.enter="sendMessage"
          ></textarea>
        </div>
      </div>
      <img src="@/assets/send.png" class="robotImg" @click="sendMessage" />
    </div>
  </div>
</template>

<script setup lang="ts">
import Clipboard from 'clipboard'
import { ref, nextTick, watch, onMounted, onUnmounted } from 'vue'
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css'

const flag = ref(false)
const messages = ref([])
const userInput = ref('')
let clipboard = null

let isStreaming = false // 是否正在流式接收数据
let streamingMessage = '' // 当前正在接收的消息

var ws = new WebSocket('ws://localhost:3004')

ws.onopen = function (e) {
  console.log('websocket连接成功', e)
}

onMounted(() => {
  console.log('window', window)
  document.addEventListener('DOMContentLoaded', function () {
    // 初始化 Clipboard.js
    new Clipboard('.copy-btn')
  })
  nextTick(() => {
    clipboard = new Clipboard('.copy-btn')
    clipboard.on('success', e => {
      console.log('复制成功')
    })
    clipboard.on('error', e => {
      console.log('复制失败')
    })
  })
})

onUnmounted(() => {
  if (clipboard) {
    clipboard.destroy()
  }
})

const md = new MarkdownIt({
  highlight: (code, lang) => {
    const linesLength = code.split(/\n/).length - 1
    const codeIndex = (
      parseInt(Date.now().toString()) + Math.floor(Math.random() * 10000000)
    ).toString()

    let html = `<button class="copy-btn" style="position: absolute;top: 0;right: 0;" type="button" data-clipboard-action="copy" data-clipboard-target="#copy${codeIndex}">复制</button>`

    if (lang && hljs.getLanguage(lang)) {
      if (linesLength) {
        html += '<b class="name">' + lang + '</b>'
      }

      const highlightedCode = hljs.highlight(code, { language: lang }).value
      return `<pre class="hljs" style="overflow-x:auto;position:relative"><code style="display:flex">${html}</code>${highlightedCode}</pre><textarea style="position: absolute;top: -9999px;left: -9999px;z-index: -9999;" id="copy${codeIndex}">${code.replace(
        /<\/textarea>/g,
        '&lt;/textarea>'
      )}</textarea>`
    }
    return ''
  }
})

// 渲染消息内容(支持 Markdown 和普通文本)
const renderMessageContent = content => {
  return md.render(content)
}

ws.onmessage = function (e) {
  const answer = JSON.parse(e.data)
  flag.value = true

  console.log('收到数据', answer)

  // 如果是流式输出,逐步更新
  if (answer == '{"reply":"","isStreaming":true}') {
    isStreaming = true
    streamingMessage = ''
    messages.value.push({ role: 'assistant', content: '' })
    console.log('流式第一次加载:', messages.value)
  } else if (isStreaming && answer.reply) {
    streamingMessage += answer.reply
    messages.value[messages.value.length - 1].content = streamingMessage
    console.log('最后的messages', messages.value)
  } else {
    isStreaming = false
  }
}

ws.onclose = function () {
  console.log('websocket已断开')
}

ws.onerror = function (error) {
  console.log('websocket发生错误' + error)
}

watch(
  messages,
  () => {
    nextTick(() => {
      document.querySelectorAll('pre code').forEach(el => {
        if (el instanceof HTMLElement) {
          hljs.highlightElement(el)
        }
      })
      // 重新初始化 Clipboard
      if (clipboard) {
        clipboard.destroy()
      }
      clipboard = new Clipboard('.copy-btn')
      clipboard.on('success', e => {
        console.log('复制成功')
      })
      clipboard.on('error', e => {
        console.log('复制失败')
      })
    })
  },
  { immediate: true }
)

// 发送用户消息
const sendMessage = () => {
  if (!userInput.value.trim()) return

  // 添加用户输入到消息列表
  messages.value.push({ role: 'user', content: userInput.value })

  // 通过 WebSocket 发送到后端
  ws.send(
    JSON.stringify({
      event: 'message',
      data: {
        content: userInput.value
      }
    })
  )

  userInput.value = '' // 清空输入框
}
</script>
<style scoped lang="less">
pre.hljs {
  padding: 12px 2px 12px 40px !important;
  border-radius: 5px !important;
  position: relative;
  font-size: 14px !important;
  line-height: 22px !important;
  overflow: hidden !important;

  code {
    display: block !important;
    margin: 10px 10px !important;
    overflow-x: auto !important;
  }
  .line-numbers-rows {
    position: absolute;
    pointer-events: none;
    top: 12px;
    bottom: 12px;
    left: 0;
    font-size: 100%;
    width: 40px;
    text-align: center;
    letter-spacing: -1px;
    border-right: 1px solid rgba(0, 0, 0, 0.66);
    user-select: none;
    counter-reset: linenumber;
    span {
      pointer-events: none;
      display: block;
      counter-increment: linenumber;
      &:before {
        content: counter(linenumber);
        color: #999;
        display: block;
        text-align: center;
      }
    }
  }
  b.name {
    position: absolute;
    top: 2px;
    right: 50px;
    z-index: 10;
    color: #999;
    pointer-events: none;
  }
  .copy-btn {
    position: absolute;
    top: 2px;
    right: 4px;
    z-index: 10;
    color: #333;
    cursor: pointer;
    background-color: #fff;
    border: 0;
    border-radius: 2px;
  }
}

.chat-container {
  padding-bottom: 30px;
  .boxHeight {
    height: 78vh;
    .chat-box {
      height: calc(100% - 60px);
      box-sizing: border-box;
      padding: 16px;
      overflow-y: auto;
      background-color: #ffffff;

      .messages {
        display: flex;
        flex-direction: column;
        gap: 12px;
      }

      .message-wrapper {
        display: flex;

        .message {
          overflow-y: auto;
          max-width: 70%;
          padding: 5px 16px;
          border-radius: 18px;
          font-size: 20px;
          line-height: 1.5;
          white-space: pre-wrap;
          word-wrap: break-word;
          box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
        }
      }

      .user-message {
        justify-content: flex-end;

        .message {
          background-color: #17c3ce;
          color: #ffffff;
          text-align: right;
          font-size: 14px;
          border-bottom-right-radius: 4px;
        }
        font-size: 20px;
      }

      .ai-message {
        justify-content: flex-start;

        .message {
          background-color: #f1f0f0;
          color: #333333;
          text-align: left;
          font-size: 14px;
          border-bottom-left-radius: 4px;
        }
      }
    }
  }
  .top {
    padding-top: 40px;
    padding-bottom: 20px;
    text-align: center;
    font-size: 24px;
    font-weight: 600;
    border-bottom: 1px solid #473f3f54;
    max-height: 4vh;
  }
  .boxImg {
    margin-top: 100px;
    text-align: center;
    margin-bottom: 120px;

    .robotImg {
      width: 160px;
      height: 160px;
    }
  }
  .chatInfor {
    padding: 0 10px;
    .chatContent {
      font-size: 14px;
      text-align: center;
      margin: 20px 0;
      padding: 30px 10px;
      background-color: #f5f5f5;
      color: #bbbbbb;
      border-radius: 20px;
    }
    .description {
      font-size: 14px;
      color: #bbbbbb;
      margin: 20px 0;
      text-align: center;
      padding: 30px 0px 30px 0;
    }
  }
  .bottom {
    // height: 4vh;
    display: flex;
    .search {
      // width: 550px;
      // margin-top: 50px;
      padding: 4px 10px 5px 10px;
      // margin-bottom: 50px;
      // margin-right: 5vw;
    }
    .robotImg {
      padding-top: 3px;
      // margin-top: 36px;
      width: 15vw;
      height: 15vw;
    }
    .input-box {
      background-color: #f5f5f5;
      width: 70vw;
      border-radius: 20px;
      // height: 40px;
      display: flex;
      align-items: center;
      gap: 8px;
      border-top: 1px solid #e5e5e5;
      padding: 4px 10px;
      .textVal {
        height: 20px;
        padding: 10px 0;
        font-size: 12px;
      }

      button {
        padding: 5px 20px;
        background-color: #0084ff;
        color: #ffffff;
        border: none;
        border-radius: 10px;
        font-size: 14px;
        cursor: pointer;
        box-shadow: 0 2px 4px rgba(0, 132, 255, 0.3);
        transition: background-color 0.3s ease;
      }

      button:hover {
        background-color: #006bbf;
      }

      button:active {
        background-color: #0056a3;
      }

      textarea {
        flex: 1;
        padding: 0 10px;
        font-size: 10px;
        border: none;
        resize: none;
        font-size: 14px;
        background-color: #f5f5f5;
        outline: none;
        // height: 30px;
      }
    }
  }
  :deep(textarea::-webkit-input-placeholder) {
    color: #9d9a9a;
    font-size: 14px !important;
  }
}
</style>

重点讲一下“流式输出的问题”,如果后端给你的返回的数据,是一次全部返还,那么你还要实现“AI几个字几个字持续输出的效果”。

这里我用了MarkdownIt和hljs插件来实现,因为大家要知道,AI输出的内容,它是纯文本形式,可用户一旦想问编程方面的知识,那么内容就很乱了。

所以MarkdownIt插件,就是用来将纯文本转换为“markdown”的插件。而hljs插件,是用来给markdown做代码高亮的插件,两者结合使用,那么效果就是如下情况:(当然你也可以将其设为深色模式,直接在我的demo里面更改即可)

image.png

另外你还要考虑到右上角的复制功能,这里我用了Clipboard插件,用以来实现,这个解决方案,在上述demo里面有,而且是可以“直接移植其他项目”的方案,万金油,随取随用!

2.前后端对接GPTgpt-4o-mini模型(Nestjs+vue3)

后端nestjs:

第一步,在src文件夹下建立两个文件,如图:

image.png

index.ts文件代码如下:

// openaiClient.js
import OpenAI from 'openai';

// 初始化 OpenAI 客户端
const openai = new OpenAI({
  apiKey: 'sk-svcacct-AFXosisZz-rSaZcFpbSfSSeV3xWMX1p1xsEOZ41UaH65GpySKuHDYWMIhlDecpWkrfRAXnn9zTbPuT3BlbkFJGEOOd_jOw8-qswjNANnmI7BYGALk8JSMuUCmuVhKK2R1NYAlPzZlwiRhekuMXw2BjbyUYA_5FpEAA', // 从环境变量中获取 API 密钥
});

// org-rI5kqxr3fGPGgHVHBwb51qHr
// 创建一个函数来生成聊天完成
async function createCompletion(model, messages) {
  try {
    const completion = await openai.chat.completions.create({
      model,
      messages,
    });
    return completion.choices[0].message;
  } catch (error) {
    console.error('Error creating completion:', error);
    throw error;
  }
}

// 导出 createCompletion 函数
export default createCompletion;

openaiClient.js代码如下:

import { Injectable } from '@nestjs/common';
import OpenAI from 'openai';

@Injectable()
export class OpenaiService {
  private openai: OpenAI;

  // 重点在这里,需要你的openai key
  constructor() {
    this.openai = new OpenAI({
      apiKey: '******************', // 确保你已经设置了环境变量
    });
  }

  async createCompletion(model: string, messages: any[]): Promise<string> {
    try {
      const response = await this.openai.chat.completions.create({
        model,
        messages,
      });
      return response.choices[0].message?.content || 'No response';
    } catch (error) {
      throw error;
    }
  }
}

第二步,在app.controller.ts文件下,引入你的service:

image.png

最后记得在app.module.ts导入进去:

image.png

这样gpt的后端服务就写完了,然后你启动一下postman/apifox,试一试:(需要打开梯子)

image.png

按照我这个接口信息格式去输入,打开梯子,即可接收到gpt回答。

对应的前端代码还是上面那一套,改一改即可。

最后的最后,奉上全部demo,大家可以把前后端下载使用+参考,有问题随时交流!

前台github代码

后台github代码

前台项目启动命令:npm run dev

后台项目启动命令:npm run start:dev

nodejs建议16以上,最好是18

有问题随时交流!