Spring AI 与前端技术融合:打造网页聊天 AI 的实战指南

1,399 阅读8分钟

前言

最近在做一个项目,项目里面有个AI聊天模块,然后我在网上查了一些文章,没有我想要手把手从头到尾教的文章,所以现在我把我学到的分享给同学们🤓,在本篇本章中你可以学到如何使用SpringAI调用大模型以及添加记忆上下文,以及Vue的自定义指令和组合式函数,还有SSE消息实时推送,实现打字机效果

最后我们会实现这样一个案例:

准备

本案例后端使用Java JDK 17,Spring AI

前端使用Vue3AntDesignVue

API-Key用的是智谱免费的AI大模型GLM-4-Flash你也可以替换成自己需要的

Spring AI的基本使用

介绍

SpringAISpring框架的一个扩展,用于方便开发者集成AI调用AI接口,有点像Langchain这种人工智能框架,SpringAI是基于Java语言的框架,对Java开发者更友好,类似还有Langchain4jLangchain4jLangchainJava版本

新建项目

我们新建一个SpringBoot项目

初始pom.xml文件内容如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.5</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.ai</groupId>
    <artifactId>code</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>code</name>
    <description>code</description>
    <url/>
    <licenses>
        <license/>
    </licenses>
    <developers>
        <developer/>
    </developers>
    <scm>
        <connection/>
        <developerConnection/>
        <tag/>
        <url/>
    </scm>
    <properties>
        <java.version>17</java.version>
        <spring-ai.version>1.0.0-SNAPSHOT</spring-ai.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

运行mvn install之后再在dependencies标签同级下添加如下内容注意是**dependencies**

    <repositories>
        <!-- 因为Spring AI还没添加到中央仓库所以要在里程碑仓库下载 -->
        <repository>
            <id>spring-milestones</id>
            <name>Spring Milestones</name>
            <url>https://repo.spring.io/milestone</url>
            <snapshots>
                <enabled>false</enabled>
            </snapshots>
        </repository>
        <repository>
            <id>spring-snapshots</id>
            <name>Spring Snapshots</name>
            <url>https://repo.spring.io/snapshot</url>
            <releases>
                <enabled>false</enabled>
            </releases>
        </repository>
    </repositories>

之后再运行mvn install,为什么要这样子一个一个添加呢?因为不这样运行mvn install就会报错

后面再添加srping-aispring-ai-zhipuai的依赖,在dependencies下添加:

        <!-- spring ai起步依赖 -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
        </dependency>
        <!--智谱ai SDK-->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-zhipuai-spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-openai</artifactId>
            <version>1.0.0-SNAPSHOT</version>
        </dependency>

dependencies同级添加:

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

运行mvn install进行安装

添加配置

我们将application文件改为application.yml添加以下配置:

server:
  port: 8080

spring:
  ai:
    openai:
      api-key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # 这里替换为你的api-key
      base-url: https://open.bigmodel.cn/api/paas/
    zhipuai: # 智谱ai的配置
      api-key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # 这里替换为你的api-key
      base-url: https://open.bigmodel.cn/api/paas/ # 这里是你调用ai的路径 这里使用的是智谱的
      chat:
        enabled: true
        options:
          model: GLM-4-FLASH # 模型 这里使用的是智谱的免费模型

我们点击进入智谱官网注册一个账号,然后获取一下api-key,添加到配置文件上

之后我们新建一个Controller,内容如下:

package com.ai.code;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.zhipuai.ZhiPuAiChatModel;
import org.springframework.ai.zhipuai.ZhiPuAiChatOptions;
import org.springframework.ai.zhipuai.api.ZhiPuAiApi;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/ai")
@Slf4j
@RequiredArgsConstructor
public class AIController {

    private final ZhiPuAiChatModel chatModel;


    @GetMapping("/message")
    public String getAIMessage(String message) {
        // 直接使用call调用
       return chatModel.call(message);
    }

}

运行项目,我们访问:http://localhost:8080/ai/message?message=hello

然后我们就获取到下面的内容

如果我们想要stream流式返回呢?几个字几个字那样打印出来,我们在controller中添加如下代码

    @GetMapping(value = "/message/stream", params = "message", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> getZhiPuAIMessage(@RequestParam String message) {

        // 流式返回
        return chatModel.stream(message);
    }

然后我们打开链接http://localhost:8080/ai/message/stream?message=hello

格式到时候我们处理一下就好了,好了到这你们应该就知道如何使用SpringAI调用AI大模型了吧?

挺简单的,这只是基本使用,接下来我们来做案例

后端实现

1.新建项目

我们新建一个SpringBoot项目并添加spring-aispring-ai-zhipu的依赖,跟上面介绍SrpingAI时一样同学们复制过来就可以了,配置也是一样的

2.新建Controller

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemory;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.zhipuai.ZhiPuAiChatModel;
import org.springframework.ai.zhipuai.ZhiPuAiChatOptions;
import org.springframework.ai.zhipuai.api.ZhiPuAiApi;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import reactor.core.publisher.Flux;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.Stream;

@RestController
@RequestMapping("/ai")
@Slf4j
@RequiredArgsConstructor
public class ChatController {

    private final ZhiPuAiChatModel chatModel;

    // 基于内存的聊天记忆
    private final ChatMemory chatMemory = new InMemoryChatMemory();


    @GetMapping(value = "/chat", params = "question", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<String>> getZhiPuAIMessage(String question,String sessionId) {

        // 使用InMemoryChatMemory进行内存存储记忆 sessionId根据id找对应的记录,只需要最近30条
        MessageChatMemoryAdvisor messageChatMemoryAdvisor = new MessageChatMemoryAdvisor(chatMemory, sessionId, 30);

       return ChatClient.create(chatModel).prompt().user(question)
           .advisors(messageChatMemoryAdvisor) // 查找记录一起发送给大模型
           .stream().content().map(
            chatResponse -> ServerSentEvent.builder(chatResponse).event("message").build());

    }



}

重新启动一下,访问一下http://localhost:8080/ai/message/stream?message=hello

如果有数据则是成功

前端实现

1.新建项目

打开控制台输入pnpm create vue@latest

创建一个最简单的TypeScript项目

使用cd chat-demo切换到该项目,然后使用pnpm i安装依赖,使用code .命名使用VSCode打开该项目

使用pnpm dev运行项目

2.安装Ant-Design-Vue

我们安装一下ant-design-vue还有@ant-design/icons-vueunplugin-vue-components

pnpm add ant-design-vue@4.x
pnpm add @ant-design/icons-vue

3.聊天页面基本样式

App.vue代码

<script lang="ts" setup>
import { PlusOutlined } from '@ant-design/icons-vue';
import { Button, Flex, Input, InputGroup, Layout, LayoutContent, LayoutFooter, LayoutHeader, LayoutSider, message, Spin } from 'ant-design-vue';
import { ref, useId } from 'vue';
type UserRole = 'system' | 'human';

const [messageApi, contextHolder] = message.useMessage();

// 聊天信息列表
const chatMessages = ref([
  {
    id: useId(),
    role: 'system',
    content: "Hi!你好,我是一个爱学习的AI,有什么问题可以问我哦",
  },
]);

// 添加聊天信息
const addChatMessage = (role: UserRole, content: string) => {
  chatMessages.value.push({ id: useId(), role, content });
};

// 用户输入内容
const userInputContent = ref('');

// 加载状态
const isLoading = ref(false);

// 发送信息
const sendChatMessage = () => {
  const inputContent = userInputContent.value.trim();
  if (!inputContent) return messageApi.error('请输入要发送的消息');

  isLoading.value = true;
  addChatMessage('human', inputContent);
  userInputContent.value = '';

  setTimeout(() => {
    addChatMessage('system', '你好,请问有什么需要帮助的?');
    isLoading.value = false;
  }, 2000);
};
</script>

<template>
  <context-holder />
  <Layout style="height: 100%;">
    <!-- 侧边栏 -->
    <LayoutSider class="sider">
      <Flex gap="middle" vertical>
        <Button type="primary" size="large" style="width: 100%;">
          <template #icon>
            <PlusOutlined />
          </template>
          新建对话
        </Button>
        <Button style="width: 100%;">当前对话</Button>
      </Flex>
    </LayoutSider>
    <Layout>
      <!-- 头部 -->
      <LayoutHeader class="header">与小Q的对话</LayoutHeader>
      <!-- 聊天内容区域 -->
      <LayoutContent class="content">
        <!-- 聊天列表 -->
        <ul class="chat-list">
          <li class="chat-item" :class="`chat-item--${msg.role}`" v-for="msg in chatMessages" :key="msg.id">
            {{ msg.content }}
          </li>
          <li class="chat-item chat-item--system" :class="{ hidden: !isLoading }">
            <Spin />
          </li>
        </ul>
      </LayoutContent>
      <!-- 底部 -->
      <LayoutFooter class="footer">
        <!-- 聊天输入框 -->
        <InputGroup>
          <Input v-model:value="userInputContent" @press-enter="sendChatMessage" />
          <Button type="primary" @click="sendChatMessage">发送</Button>
        </InputGroup>
      </LayoutFooter>
    </Layout>
  </Layout>
</template>

<style lang="scss" scoped>
:deep(.md-editor-preview-wrapper) {
  padding: 0;
  background-color: transparent;


}

.header {
  background-color: #fff;
  border-bottom: 1px solid #f0f0f0;
}

.content {
  display: flex;
  background-color: #fff;
  overflow: hidden;
}

.footer {
  padding: 16px;
  background-color: #fff;
}

.sider {
  display: flex;
  justify-content: stretch;
  padding: 10px;
  border-right: 1px solid #f0f0f0;
  background-color: #fff;
}

.hidden {
  display: none;
}

:deep(.ant-layout-sider-children) {
  width: 100%;
}

:deep(.ant-input-group) {
  display: flex !important;
}

:deep(.ant-input) {
  height: 45px;
}

:deep(.ant-btn) {
  height: 45px;
}

.chat-list {
  display: flex;
  flex-direction: column;
  list-style-type: none;
  flex: 1;
  width: 100%;
  padding: 30px !important;
  gap: 30px;
  margin: 0;
  padding: 0;
  overflow-y: auto;

  .chat-item {
    line-height: 30px;
    padding: 3px 12px;
    border-radius: 10px;

    &--human {
      background-color: #1677ff;
      align-self: flex-end;

      :deep(p) {
        color: #fff;
      }
    }

    &--system {
      align-self: flex-start;
      border: 1px solid #f0f0f0;
    }
  }
}
</style>

我们运行,就会得到这个页面,测试一下

4.请求后端API

如果我们要实现打字机的效果,我们就要使用Server-Sent Events(SSE)协议它提供了一种从服务器单向推送消息到客户端的机制。这使得服务器能够在有新的数据可用时,自动将数据发送给客户端,而不需要客户端不断地发起请求来获取更新。从而实现打字机效果

我们先安装需要用到的依赖

pnpm add @microsoft/fetch-event-source

相较于EventSource API,@microsoft/fetch-event-source更有优势

自带的EventSource有许多局限:

  1. 不能传递请求体body
  2. 不能传递自定义请求头
  3. 只能发送GET请求方式,无法指定其它方法
  4. 如果连接被切断,无法进行重试

接着我们在代码中使用

/**会话id*/
const sessionId = useId()

// 发送信息
const sendChatMessage = () => {
  const inputContent = userInputContent.value.trim();
  if (!inputContent) return messageApi.error('请输入要发送的消息');
  // 加载状态
  isLoading.value = true;
  // 添加用户消息
  addChatMessage('human', inputContent);
  userInputContent.value = '';

  setTimeout(async () => {
    // 添加ai消息
    addChatMessage('system', '你好,请问有什么需要帮助的?');
    // 请求获取ai回复数据
    await fetchChatAIData(inputContent)
    isLoading.value = false;
  }, 2000);
};

/**
 * 获取ai消息数据
 * @param question 问题
 */
const fetchChatAIData = async (question: string) => {
  await fetchEventSource(`http://localhost:8080/ai/chat?question=${question}&sessionId=${sessionId}`, {
    headers: {
      "Content-Type": "application/json" // 设置请求头
    },
    // 监听消息
    onmessage(event) {
      console.log(event);
    },
    // 关闭
    onclose() {
      console.log("连接关闭");
    },
    // 错误
    onerror(err) {
      console.log('连接错误');
      ctrl.abort()
    },
    // 控制请求取消的信号
    signal: ctrl.signal
  })
}

我们运行项目看看,我们发送一条消息试试

结果发生了跨域的错误

那我们在SpringBoot项目中配置一下跨域,添加WebMvcConfig的配置

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@RequiredArgsConstructor
@Slf4j
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        // 添加映射路径,允许所有路径跨域
        registry.addMapping("/**")
            // 允许所有来源跨域
            .allowedOriginPatterns("*")
            // 允许所有请求方法跨域
            .allowedMethods("*")
            // 允许所有请求头跨域
            .allowedHeaders("*")
            // 是否允许携带凭证(cookies)
            .allowCredentials(true);
    }

}

我们重启,再看一下前端项目

如我所愿返回了数据

5.处理渲染数据

那我们处理一下吧数据渲染到页面上,因为我们要实现的是打字机的效果,所以不能直接使用addMessage添加消息,我们先写一个占位符显示后端返回的stream数据流

先写一个消息占位符

/**占位符 */
const messagePlaceholder = ref('')

接着我们fetchChatAIData这个方法里,将接收到的消息拼接占位符

const fetchChatAIData = async (question: string) => {
  await fetchEventSource(`http://localhost:8080/ai/chat?question=${question}`, {
    // ...
    // 监听消息
    onmessage(event) {
      console.log(event);
      // 拼接占位符
      messagePlaceholder.value += event.data
    },
    // ...
  })
}

然后我们加到信息列表的加载动画前面

<li class="chat-item chat-item--system" :class="{ hidden: !isLoading }">
            {{ messagePlaceholder }}
            <Spin />
</li>

为了测试方便我将sendMessage发送消息的方法改成了这样

const sendChatMessage = async () => {
  const inputContent = userInputContent.value.trim();
  if (!inputContent) return messageApi.error('请输入要发送的消息');
  // 加载状态
  isLoading.value = true;
  // 添加用户消息
  addChatMessage('human', inputContent);
  userInputContent.value = '';

  // 添加ai消息
  // addChatMessage('system', '你好,请问有什么需要帮助的?');
  // 请求获取ai回复数据
  await fetchChatAIData(inputContent)
  // isLoading.value = false;
};

接着我们点击进入http://localhost:5173/ 测试看一下(记得启动后端)

嗯是我想要的效果,然后接下来我们添加到消息列表上

const fetchChatAIData = async (question: string) => {
  await fetchEventSource(`http://localhost:8080/ai/chat?question=${question}`, {
    headers: {
      "Content-Type": "application/json" // 设置请求头
    },
    // 监听消息
    onmessage(event) {
      console.log(event);
      messagePlaceholder.value += event.data
    },
    // 关闭
    onclose() {
      console.log("连接关闭");
      //添加消息到消息列表上
      addChatMessage('system', messagePlaceholder.value);
      // 清空占位符
      messagePlaceholder.value = ''
      isLoading.value = false;
    },
    // 错误
    onerror(err) {
      console.log('连接错误');
      ctrl.abort()
      isLoading.value = false;
    },
    // 控制请求取消的信号
    signal: ctrl.signal
  })
}

我们看看效果

是我想要的呢😉,不过有个问题,就是超出聊天框时不会自动滚动到底部

那我们做一下这部分的逻辑吧,在添加消息和接收消息那里添加一行代码就可以

// 发送信息
const sendChatMessage = async () => {
  // ...
  // 添加用户消息
  addChatMessage('human', inputContent);
  // 滚动到最后一个元素
  messageListRef.value?.lastElementChild?.scrollIntoView(false)
  // ...
};


const fetchChatAIData = async (question: string) => {
  await fetchEventSource(`http://localhost:8080/ai/chat?question=${question}`, {
    // ...
    // 监听消息
    onmessage(event) {
      console.log(event);
      messagePlaceholder.value += event.data
      // 滚动到最后一个元素
      messageListRef.value?.lastElementChild?.scrollIntoView(false)

    },
    // ...
  })
}

不过渲染的格式不对,返回的是markdown格式的,所以我们要使用markdown渲染,我们安装一下md-editor-v3用来渲染markdown

pnpm add md-editor-v3

我们导入使用

import { MdPreview } from 'md-editor-v3';
import 'md-editor-v3/lib/preview.css';

渲染

 <li class="chat-item" :class="`chat-item--${msg.role}`" v-for="msg in chatMessages" :key="msg.id">
     <MdPreview style="padding: 0; background-color: transparent;" :model-value="msg.content" />
</li>
<li class="chat-item chat-item--system" :class="{ hidden: !isLoading }">
    <MdPreview :model-value="messagePlaceholder" />
    <Spin />
</li>

在监听消息那里修改一下

  // 监听消息
onmessage(event) {
  // 在一次或多次出现# 后面添加一个空格
  messagePlaceholder.value += event.data.replace(/#+/g, event.data + " ")
  // 滚动到最后一个元素
  messageListRef.value?.lastElementChild?.scrollIntoView(false)
},

测试一下

不错可以了,但是还是有些问题

6.优化

现在页面有点乱我们优化一下

封装自动滚动指令

我们封装一个自动滚动指令,只要有消息发送就自动滚动到聊天框底部,可以多次复用,只要用到的地方就加一个v-auto-scroll就可以,不了解Vue3的自定义指令可以点击进入官方文档进行了解

我们新建一个directive.ts文件

import type { Directive } from "vue";

/**
 * 自动滚动指令
 */
export const autoScrollDirective: Directive = {
  // 在绑定元素的父组件
  // 及他自己的所有子节点都更新后调用
  updated(el: HTMLElement, binding) {
    if (!binding.arg) throw Error("请传入arg参数")
    // 获取arg参数执行对应操作 方便后续扩展
    switch (binding.arg) {
      case 'bottom':
        // 判断是否能滚动
        if (!isVerticalScrollable) throw Error('该元素不能纵向滚动 请设置style')
        if (!el.lastElementChild) return
        // 滚动到最后一个元素
        el.lastElementChild.scrollIntoView(false)
        break
      default:
        throw Error("当前该指令仅支持 bottom")
    }
  }
}

arg参数是v-auto-scroll:bottom中的bottom

其中isVerticalScrollable是一个检查元素是否能纵向滚动的工具函数

/**
 * 检查当前元素是否可纵向滚动
 * @param element 检查的元素
 * @returns 
 */
export const isVerticalScrollable = (element: HTMLElement) => {
  const style = window.getComputedStyle(element);

  return (
    style.overflow === 'scroll' ||
    style.overflow === 'auto' ||
    style.overflowY === 'auto' ||
    style.overflowY === 'scroll'
  )
}

我们在main.ts中注册全局指令


const app = createApp(App)
// 注册全局指令
app.directive("auto-scroll", autoScrollDirective)
app.mount('#app')

注册全局指令一定要在app挂在之前注册,要不然会报错的

我们在模板中使用

 <ul class="chat-list" ref="messageListRef" v-auto-scroll:bottom>
        <!-- ... -->
 </ul>

然后把添加消息之后滚动的代码删掉

messageListRef.value?.lastElementChild?.scrollIntoView(false)

测试一下http://localhost:5173/,可以的,这样就少了两行代码了,这样做的好处就是将来要在其他地方使用的使用,添加以下v-auto-scroll指令就可以了,比如聊天室页面,还学到了Vue3的自定义指令

封装请求fetchChatAIData

我们封装fetchChatAIData组合式函数,新建一个useFetchChatAIData.ts组合式函数,代码如下:

import { fetchEventSource } from "@microsoft/fetch-event-source"
import { ref, useId } from "vue"

/**
 * 抓取聊天ai消息
 * @returns 
 */
export const useFetchChatAIData = () => {
  /** 消息加载状态 */
  const isLoading = ref(false)
  /** 返回的数据 */
  const value = ref('')
  const ctrl = new AbortController()
  /** 会话id */
  const sessionId = useId()
  /** 获取函数 */
  const fetchChatAIData = async (message: string | number) => {
    try {
      isLoading.value = true
      await fetchEventSource(`http://localhost:8080/ai/chat?question=${message}&sessionId=${sessionId}`, {
        headers: {
          "Content-Type": "application/json"
        },
        onmessage(event) {
          console.log("message:" + event.data);

          value.value = event.data.replace(/#+/g, event.data + " ")
        },
        onclose() {
          console.log("连接关闭");
        },
        onerror(err) {
          ctrl.abort()
        },
        signal: ctrl.signal
      })
    } catch (error) {
      throw Error('获取信息失败:' + error)
    } finally {
      isLoading.value = false
    }

  }

  return {
    value,
    isLoading,
    fetchChatAIData
  }

}

isLoading是消息的加载状态,value是请求的返回消息,fetchChatAIData是请求数据的方法

我们在App.vue中使用

const { fetchChatAIData, value, isLoading } = useFetchChatAIData()

然后将App.vuefetchChatAIDataisLoading删了,我们将onSendMessage的代码修改:

/**发送信息 */
const sendChatMessage = async () => {
  const inputContent = userInputContent.value.trim();
  if (!inputContent) return messageApi.error('请输入要发送的消息');
  // 添加用户消息
  addChatMessage('human', inputContent);
  // 清空用户输入框
  userInputContent.value = '';
  // 请求获取ai回复数据
  await fetchChatAIData(inputContent)

  // 请求完毕之后追加到消息列表
  addChatMessage('system', messagePlaceholder.value)
  // 清空占位符
  messagePlaceholder.value = ''
};

我们添加一个事件监

// 监听消息变化 修改占位符的值
watch(value, value => {
  messagePlaceholder.value = value
})

如果返回的消息发生了变化就修改占位符的值

结果也是可以的,我们又学习了如何封装一个组合式函数,在 Vue 应用的概念中,“组合式函数”(Composables) 是一个利用 Vue 的组合式 API 来封装和复用有状态逻辑的函数。有点像ReactHook

这样我们实现了一个Web端的聊天AI

总结

总结一下吧

  • 在本文章中我们实现了一个网页AI聊天机器人
  • 学习了如何使用SpringAI调用大模型,基于内存的存储上下文记忆
  • 学习了前端如何使用SSE接收后端返回的stream流实现打字机效果
  • 学习了使用md-editor-v3渲染markdown格式的数据
  • 学习了Vue的自定义指令
  • 学习了如何封装组合式函数

如果有问题可以在评论区分享一下