Bootstrap

SpringBoot3+Vue3极速搭建DeepSeek R1 AI 助手

第1章:效果演示与环境介绍

效果演示:见视频地址,上某站搜索菩提老师免费看视频

环境介绍:

后端:Spring Boot3.4.2+JDK21

前端:Vue3.3.4+Vite4.4.6

第2章:从零搭建SpringBoot3 Web服务

1、创建Spring Boot3空的Web项目

选择两个依赖项:

2、修改pom.xml文件

1、删除暂时无用的标签

2、增加pom属性

<properties>
    <maven.compiler.source>21</maven.compiler.source>
    <maven.compiler.target>21</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <java.version>21</java.version>
</properties>

3、添加依赖:

<dependencies>
    <!--        web服务-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!--        这里由于后期需要把前端项目不分离的融合到后端,所以引入Thymeleaf模板引擎-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <!--        阿里云百炼大模型的SDK-->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>dashscope-sdk-java</artifactId>
        <version>2.18.2</version>
    </dependency>
    <!--        lombok-->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <scope>provided</scope>
    </dependency>
</dependencies>

4、修改build打包标签

<build>
    <finalName>AiApp</finalName>

    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <configuration>
                <annotationProcessorPaths>
                    <path>
                        <groupId>org.projectlombok</groupId>
                        <artifactId>lombok</artifactId>
                    </path>
                </annotationProcessorPaths>
            </configuration>
        </plugin>
        <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>

5、完整的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.4.2</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.ai</groupId>
    <artifactId>ai-admin</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>ai-admin</name>
    <description>ai-admin</description>
    <properties>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <java.version>21</java.version>
    </properties>
    <dependencies>
        <!--        web服务-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--        这里由于后期需要把前端项目不分离的融合到后端,所以引入Thymeleaf模板引擎-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <!--        阿里云百炼大模型的SDK-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>dashscope-sdk-java</artifactId>
            <version>2.18.2</version>
        </dependency>
        <!--        lombok-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.32</version>
            <scope>provided</scope>
        </dependency>

    </dependencies>

    <build>
        <finalName>AiApp</finalName>
        <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>

3、编写AI控制器,先写一个空白页面试试效果

思考这里为什么不是@RestController注解?

@Controller
@Slf4j
public class AiController {
    @RequestMapping("/")
    String index() {
        return "index";
    }
}

再编写一个index.html文件,内容随便写,比如哈哈哈。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1>hello Ai</h1>
</body>
</html>

注意:index.html文件需要放到resources资源目录下的templates目录下,关于Thymeleaf模板引擎不了解的可以去看菩提老师的文档或课程

4、修改application.properties文件

把application.properties改为application.yml,不改也行,但为什么要改?那就需要你知道yml文件比properties文件的优势了。

至于里边的配置,不改也罢,端口改为80,方便开发,浏览器懒得输入端口号。

5、启动主启动程序,访问http://localhost

6、修改项目的编译为JDK21

7、在AI控制器中增加/sse接口

sse技术叫(Server Event Send)服务端单向推送消息给客户端,跟webSocket双向长连接有些区别,当然也可以使用WebSocket,这里我们就使用SSE技术。

@Controller
@Slf4j
public class AiController {
    SseEmitter emitter = new SseEmitter();

    @RequestMapping("/")
    String index() {
        return "index";
    }
    @GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    @ResponseBody
    public SseEmitter streamEvents() {
        emitter = new SseEmitter();
        return emitter;
    }
}

由于这里需要返回一个SseEmitter 对象,所以这里需要加@ResponseBody

创建的这个SseEmitter 对象,是用来给前端不断推送大模型消息的,所以应该把他存起来,当前端连接这个sse接口的时候,就会产生一个新的SseEmitter对象,因此,需要把这个对象保存起来。这里为了简单,我们就连接一次就创建一个唯一的,覆盖之前的,实际是每一个前端连接都会创建一个专属于它的连接对象,所以应该按照Map把每个连接对象存起来,到时候再按照key取出前端自己的SseEmitter对象进行消息的推送。

8、获取阿里百炼大模型的API-Key

登录阿里云控制台,找到大模型服务平台百炼

创建一个apKey,等会备用

9、准备一个接受请求的请求对象AiReq:

package com.ai.aiadmin.pojo.req;

import com.alibaba.dashscope.common.Message;
import lombok.Data;

import java.io.Serializable;
import java.util.List;

@Data
public class AiReq implements Serializable {
    private String mode;// 调用哪种大模型,比如deepseek-r1、qianwen-plus等
    private List<Message> msgs;// 与对话的消息和用户输入的消息
    private boolean incrementalOutput;// 是否增量输出
}

10、准备一个ResponseDTO对象,统一给前端的返回对象

package com.ai.aiadmin.pojo.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

/**
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ResponseDTO<T> implements Serializable {
    /**
     * 返回的消息
     */
    private String msg;

    /**
     * 返回成功的消息
     */
    private static String successMsg = "操作成功";

    /**
     * 返回错误的消息
     */
    private static String errorMsg = "操作失败";
    /**
     * 返回的状态
     */
    private Integer status = 200;
    /**
     * 返回的结果
     */
    private boolean res = true;

    /**
     * 返回的错误码
     */
    private Integer errorCode;
    /**
     * 返回的数据
     */
    private T data;

    /**
     * 返回成功的Code
     */
    private Integer okCode;

    /**
     * 返回为成功的构造函数
     *
     * @param msg
     * @param data
     */
    public ResponseDTO(String msg, T data) {
        this.msg = msg;
        this.data = data;
    }

    public ResponseDTO(String msg, T data, Integer okCode) {
        this.msg = msg;
        this.data = data;
        this.okCode = okCode;
    }

    /**
     * 构造失败的回调结果
     *
     * @param msg
     * @param data
     * @param errorCode
     * @param status
     */
    public ResponseDTO(String msg, T data, Integer errorCode, Integer status) {
        this.msg = msg;
        this.data = data;
        this.errorCode = errorCode;
        this.status = status;
        this.res = false;
    }

    public ResponseDTO(String msg, int status, Boolean res, T data) {
        this.msg = msg;
        this.res = res;
        this.status = status;
        this.data = data;
    }


    /**
     * 调用成功的时候,返回成功的状态
     *
     * @param msg
     * @param data
     * @return
     */
    public static <T> ResponseDTO<T> ok(String msg, T data) {
        return new ResponseDTO(msg, data);
    }

    /**
     * 调用成功的时候,返回成功的状态
     *
     * @param msg
     * @param data
     * @return
     */
    public static <T> ResponseDTO<T> ok(String msg, T data, Integer okCode) {
        return new ResponseDTO(msg, data, okCode);
    }

    /**
     * 调用成功的时候,返回成功的状态
     *
     * @return
     */
    public static <T> ResponseDTO<T> get(boolean res) {
        if (res) {
            return ResponseDTO.ok(successMsg);
        } else {
            return ResponseDTO.fail(errorMsg);
        }
    }

    public static <T> ResponseDTO<T> get(T data) {
        if (data != null) {
            return ResponseDTO.ok(successMsg, data);
        } else {
            return ResponseDTO.fail(errorMsg);
        }
    }

    public static <T> ResponseDTO<T> ok(String msg) {
        return new ResponseDTO(msg, null);
    }

    public static <T> ResponseDTO<T> ok() {
        return new ResponseDTO("操作成功", null);
    }

    /**
     * 调用失败的时候,返回失败的状态
     *
     * @param msg
     * @param data
     * @return
     */
    public static <T> ResponseDTO<T> fail(String msg, T data, Integer errorCode, Integer status) {
        return new ResponseDTO(msg, data, errorCode, status);
    }

    /**
     * 调用失败的时候,返回失败的状态
     */
    public static <T> ResponseDTO<T> fail(String msg) {
        return new ResponseDTO(msg, null, null, null);
    }
    public static <T> ResponseDTO<T> fail(String msg,T data) {
        return new ResponseDTO(msg, data, null, null);
    }
    public static <T> ResponseDTO<T> fail() {
        return new ResponseDTO(errorMsg, null, null, null);
    }
}

11、在AiController编写一个接口,接受用户点击发送时候传递的请求数据


// 接受前端send的请求数据
@RequestMapping("/ai")
@ResponseBody
public ResponseDTO<Boolean> ai(@RequestBody AiReq aiReq) {
    CompletableFuture.runAsync(() -> {
        GenerationParam param = buildGenerationParam(aiReq);
        Flowable<GenerationResult> result = null;
        try {
            result = new Generation().streamCall(param);
        } catch (NoApiKeyException e) {
            throw new RuntimeException(e);
        } catch (InputRequiredException e) {
            throw new RuntimeException(e);
        }
        result.blockingForEach(this::handleGenerationResult);
    });

    return ResponseDTO.ok();
}
// 生成大模型的参数
private static GenerationParam buildGenerationParam(AiReq aiReq) {
    return GenerationParam.builder()
            // 若没有配置环境变量,请用百炼API Key将下行替换为:.apiKey("sk-xxx")
            .apiKey("第八步的API-KEY")
            .model(aiReq.getMode())
            .messages(aiReq.getMsgs())
            .resultFormat(GenerationParam.ResultFormat.MESSAGE)
            .incrementalOutput(true)
            .build();
}

// 处理大模型调用返回的结果
private void handleGenerationResult(GenerationResult message) {
    log.debug(":{}",message);
    Message msg = message.getOutput().getChoices().get(0).getMessage();
    try {
        // 用emitter给前端推送消息
        emitter.send(SseEmitter.event()
                .data(msg)
                .id(message.getRequestId()));
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

12、配置跨域请求和前端静态资源代理

package com.ai.aiadmin.config;

import org.springframework.boot.SpringBootConfiguration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * creator:菩提老师
/**
 * webMVC的配置类
 * 这个就是MVC核心接口:能够配置拦截器,能够配置跨域访问,还能配置静态资源,还能配置很多东西
 */
@SpringBootConfiguration
public class WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/assets/**")
                .addResourceLocations("classpath:/templates/dist/assets/");
        registry.addResourceHandler("/config.js")
                .addResourceLocations("classpath:/templates/dist/");
        registry.addResourceHandler("/favicon.ico")
                .addResourceLocations("classpath:/templates/dist/");
    }


    /**
     * 配置跨域请求
     *
     * @param registry
     */
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .allowCredentials(false).maxAge(3600)
                .allowedOrigins("*")
        ;
    }
}

这里跨域请求主要是为了支持开发环境时前后端分离的请求

配置静态资源代理是为了前端编写好之后,放到resources/templates目录下做静态资源配置用的

🚩需要注意的是,vue项目打包之后,只有一个index.html文件,并且他的目录结构是这样的:

所以为了方便我们直接复制dist目录,于是在WebMvcConfig配置类中是那样写,同时,AIController中需要跳转到dist目录下的index.html文件:

@RequestMapping("/")
String index() {
    return "dist/index";
}

这样才能正确展示

后端先告一段落。

第3章:编写Vue3前端聊天界面:

1、创建Vue3空项目

简单说一下从零创建Vue3.x项目,会的直接跳过,也可以全面更详细的去看菩提老师讲的Vue3.x+Vite4企业实战。

首先安装Nodejs:Node.js — Run JavaScript Everywhere,Nodejs是对JavaScript进行编译的一个工具,这样就可以在没有浏览器的情况下,也可以使用JS,好比Java的JDK。

查看安装的版本

C:\Users\23737>node -v

v18.17.1

C:\Users\23737>npm -v

9.6.7

看到以上的信息,表示nodejs安装好了,接下来配置淘宝镜像:

1.通过cnpm使用淘宝镜像:npm install -g cnpm --registry=http://registry.npm.taobao.org

2.将npm设置为淘宝镜像:npm config set registry http://registry.npm.taobao.org

3.将cnpm设置为淘宝镜像:cnpm config set registry http://registry.npm.taobao.org

4.查看npm镜像设置:npm config get registry

5.查看cnpm镜像设置:cnpm config get registry 

开发前端项目的常用或者热门开发用具IDEA(VS Code(免费)、WebStorm(idea一个公司( JetBrains),收费)、Idea(可以用来开发前端项目) )

目前Vue官网的是 vue3.7.3+Vite4+

2、前端要新,后端要稳

cnpm init vue@latest(注意:需要先把Nodejs安装上,你可以理解为类似Maven工具,下载依赖用的,类似Spring Boot Web项目))

选择各种选项:

✔ Project name: … <your-project-name> #项目名称 默认就是文件夹名称
✔ Add TypeScript? … No / Yes  是否增加TypeScript语法,(就是js带类型)选择(NO)
✔ Add JSX Support? … No / Yes  JS语法扩展选择(NO)
✔ Add Vue Router for Single Page Application development? … No / Yes (Vue路由:实现单页应用)选择(Yes)
✔ Add Pinia for state management? … No / Yes (状态管理 早的时候是VueX,现在是pinia)选择(Yes)
✔ Add Vitest for Unit testing? … No / Yes 选择(测试?NO)
✔ Add Cypress for both Unit and End-to-End testing? … No / Yes 选择(No)
✔ Add ESLint for code quality? … No / Yes 选择(No)
✔ Add Prettier for code formatting? … No / Yes 选择(NO)
Scaffolding project in ./<your-project-name>...
Done.

进入到项目所在的目录

cd

安装依赖:

cnpm install

运行:cnpm run dev

打包:cnpm run build

OK,前端项目搭建完毕!

3、封装前端请求工具:RequestUtil.ts

import axios from 'axios';
import {message} from "ant-design-vue";
import router from "@/router/router";

// 构建axios实例
export const http = axios.create({
    baseURL: import.meta.env.BASE_URL, // 该处url会根据开发环境进行变化(开发/发布)
    timeout: 2000000, // 设置请求超时连接时间
})
let running = false;

export const tokenName: string = 'lzket.token';
console.log('域名:', import.meta.env.VITE_domain)
// 域名配置:重要的!
export const domainConfig = {
    https: import.meta.env.MODE == 'development' ? window.config.dev.https : window.config.pro.https,
    domain: import.meta.env.MODE == 'development' ? window.config.dev.domain : window.config.pro.domain,
    getApiPrefix() {
        return this.getUrlPrefix()
    },
    getUrlPrefix() {
        return this.https ? "https://" : "http://" + this.domain
    }
}

http.interceptors.request.use(
    async (config: any) => {
        config.url = domainConfig.getApiPrefix() + config.url;
        return config // 对config处理完后返回,下一步将向后端发送请求
    },
    error => { // 当发生错误时,执行该部分代码
        console.log(error); //调试用
        return Promise.reject(error)
    }
)

http.interceptors.response.use(
    (response: any) => { // 该处为后端返回整个内容
        let res = response.data; // 该处可将后端数据取出,提前做一个处理
        // console.log('后端返回对象', response);
        let token = response.headers.token;// 后端返回的token
        if (response.headers.token) {
            localStorage.setItem(tokenName, token);
            localStorage.setItem("tokenexpiration", response.headers.tokenexpiration);
        }
        if (res.errorCode == 401) {
            // session.showLoginModal = true;
            message.warn(res.msg);
            console.log('goBackUrl', window.location.href);
            localStorage.setItem('goBackUrl', window.location.href)
            router.push('/loginPage')
            return Promise.reject('401未登录')
        } else {
            return res;// 默认都返回
        }

    },
    error => {
        // console.log(error);
        message.error(error.message)
        return Promise.reject(error)
    }
)

4、编写消息组件

<script setup lang="ts">
import {LoadingOutlined} from '@ant-design/icons-vue'

let p = defineProps(['msg']);
</script>

<template>
  <div class="out-ai-msg">
    <div v-if="p.msg.role=='assistant'">
      <div style="display: flex">
        <div>
          <a-tag color="success">{{ p.msg.agent }}:</a-tag>
        </div>
        <div style="flex: 1;text-align: left">
          <div class="ponder" v-if="p.msg.reasoningContent" v-html="p.msg.reasoningContent"></div>
          <LoadingOutlined v-show="p.msg.loading"/>
          <div v-html="p.msg.markedContent"></div>
        </div>
      </div>
    </div>
    <div v-else-if="p.msg.role=='user'" class="out-msg">
      <div class="user-msg">
        <div>{{ p.msg.content }}</div>
      </div>
    </div>
  </div>

</template>

<style scoped lang="less">

.out-ai-msg {
  .ponder {
    color: #8b8b8b;
    white-space: pre-wrap;
    margin: 0;
    padding: 0 0 10px 13px;
    line-height: 26px;
    position: relative;
    border-left: solid 2px lightgray;
  }

  .out-msg {
    justify-content: flex-end;
    margin-bottom: 16px;
    padding-bottom: 32px;
    display: flex;

    .user-msg {
      display: inline-block;
      background: #eff6ff;
      padding: 8px 20px;
      font-size: var(--ds-font-size-l);
      line-height: var(--ds-line-height-l);
      color: #262626;
      box-sizing: border-box;
      white-space: pre-wrap;
      word-break: break-word;
      border-radius: 14px;
      max-width: calc(100% - 48px);
      position: relative;
    }
  }
}
</style>

5、把静态资源,比如图片字体拷贝到assets目录下去

6、注意事项:

由于大模型返回的是markedown文档格式的文字,所以我们需要把大模型返回的文本转换成markedown文档,因此需要安装marked依赖包在前端:

npm install marked

此外,我们为了记录每个消息,包括用户发送的消息的唯一性,安装uuid依赖,在前端生成消息的唯一id,后端返回的大模型消息是自带uuid的,因此只需要在前端发送消息的时候生成uuid作为msg的唯一id即可

npm install uuid

7、编写聊天界面:index-main.vue

<template>
  <a-layout class="cm-ai">
    <a-layout-sider class="sider" width="260">
      <div class="logo-div">
        AI Assistant
      </div>
      <div>
        <a-space>
          <a-avatar :src="putiSrc" />
          <div style="text-align: left">
            菩提老师<br>WX:zhihoubuhuo
          </div>
        </a-space>
      </div>
      <div>
        <a-list size="small" :data-source="questionItems">
          <template #renderItem="{ item }">
            <a-list-item @click="viewHistory(item)"
                         :style="{background:d.currentItemKey==item.key?'#e6f4ff': '',color:d.currentItemKey==item.key?'#3a8cff':'',border:'none'}">
              <div>
                {{ item.label }}
              </div>
              <template #actions>

                <a-popconfirm
                    title="Are you sure delete this history?"
                    ok-text="Yes"
                    cancel-text="No"
                    @confirm="deleteHistory(item)"
                >
                  <DeleteOutlined/>
                </a-popconfirm>
              </template>
            </a-list-item>
          </template>
          <template #header>
            <div>question history</div>
          </template>
        </a-list>
      </div>
    </a-layout-sider>
    <a-layout>
      <!--            <a-layout-header :style="headerStyle">FAB · AI Tool Solution</a-layout-header>-->
      <a-layout-content :style="contentStyle">
        <div
            style="align-items: center;justify-content: center;display: flex;padding: 30px 10px 0 0;height: calc(100vh - 260px)">
          <div ref="msgDiv" style="width: 800px;height: 100%;overflow-y: auto;">
            <msg v-for="msg in d.msgs" :msg="msg"></msg>
          </div>
        </div>
      </a-layout-content>
      <a-layout-footer :style="footerStyle">
        <div style="align-items: center;justify-content: center;display: flex;">
          <div style="width: 800px">
            <a-card size="small" style="max-width: 798px;width: 100%;min-width: 200px">
              <!--              <template #extra v-if="d.mode=='deepseek-r1'">-->
              <!--                <a-switch  v-model:checked="d.incrementalOutput" checked-children="思考过程-开" un-checked-children="思考过程-关" />-->
              <!--              </template>-->
              <template #title>
                <a-space>
                  model:
                  <a-select
                      ref="select"
                      v-model:value="d.mode"
                      style="min-width: 200px"
                  >
                    <a-select-option value="cm-model" disabled>CM-Model (And So On)</a-select-option>
                    <a-select-option value="deepseek-r1">DeepSeek R1</a-select-option>
                    <a-select-option value="qwen-plus">千问-Plus</a-select-option>
                    <a-select-option value="llama3.3-70b-instruct">Llama3.3</a-select-option>
                    <a-select-option value="abab6.5s-chat">MiniMax大模型</a-select-option>
                  </a-select>
                </a-space>

              </template>

              <a-textarea @keydown="(e)=>{if(e.keyCode==13&&!e.shiftKey){sendMsg()}}" v-model:value="d.content"
                          placeholder="Send messages to AI" :rows="d.rows"
                          :autoSize="{ minRows: 2, maxRows: 6 }"/>
              <div>
                <a-button type="primary" :disabled="!d.canSend" @click="sendMsg">Send</a-button>
              </div>

            </a-card>
          </div>
        </div>
        Content is generated by AI, please check it carefully
      </a-layout-footer>
    </a-layout>
  </a-layout>
</template>
<script lang="ts" setup>
import {computed, type CSSProperties, onMounted, reactive, ref} from 'vue';
import Msg from "@/components/msg.vue";
import {domainConfig, http} from "@/util/RequestUtil";
import {message} from "ant-design-vue";
import {v4} from 'uuid';
import {DeleteOutlined} from '@ant-design/icons-vue'

const msgDiv = ref(null);
import {marked} from 'marked';
import putiSrc from '@/assets/images/puti.jpg'
import wxImg from '@/assets/my_wx.png'
import codeImg from '@/assets/wx_puclic_cod.jpg'

let questionItems = computed(() => {
  return d.questions.map(q => ({
    key: q.userMsg.id,
    label: q.userMsg.content,
    question: q
  }))
})

let deleteHistory = (item) => {
  d.questions.splice(d.questions.findIndex(e => e.userMsg.id == item.key), 1);
  localStorage.setItem("cm-questions", JSON.stringify(d.questions));
}
let loadLocalStorageQuestions = () => {
  let questions = localStorage.getItem("cm-questions");
  if (questions == null) {
    questions = JSON.stringify([]);
  }
  d.questions = JSON.parse(questions);
}
let d = reactive({
  questions: [],
  currentItemKey: '',
  selectedKeys: [],
  incrementalOutput: true,
  content: '',
  userCurrentHistoryContent: '',
  mode: 'deepseek-r1',
  rows: 2,
  msgs: [],
  canSend: true,
})
let viewHistory = (item) => {
  d.msgs = [];
  d.msgs[0] = item.question.userMsg
  d.msgs[1] = item.question.aiMsg
  d.currentItemKey = item.question.userMsg.id
}

onMounted(() => {
  loadLocalStorageQuestions();
  let eventSource = new EventSource(domainConfig.getApiPrefix()+'/sse');
  eventSource.onmessage = function (event) {
    console.log(event)
    let msg = JSON.parse(event.data);
    msg.id = event.lastEventId;
    msg.agent = d.mode;
    console.log('msg:', msg)
    let old = d.msgs.find(e => e.id === msg.id);
    if (old) {
      let loading1 = true;
      let loading2 = true;
      if (msg.reasoningContent) {
        loading1 = true;
        old.reasoningContent += msg.reasoningContent;
      } else {
        loading1 = false;
      }
      if (msg.content) {
        loading2 = true;
        old.content += msg.content;
        old.markedContent = marked.parse(old.content);
      } else {
        loading2 = false;
      }
      old.loading = loading1 || loading2;// loading为true表示在编写中,false表示本次对话结束
      if (!old.loading) {
        d.canSend = true;
        let q = {
          userMsg: {role: 'user', content: d.userCurrentHistoryContent, id: v4()},
          aiMsg: old
        }
        d.questions.push(q);
        localStorage.setItem("cm-questions", JSON.stringify(d.questions));
      } else {
        d.canSend = false
      }
    } else {
      msg.loading = true;
      msg.markedContent = marked.parse(msg.content);
      d.msgs.push(msg);
    }
    scrollMsgDivToBottom();
  };

  eventSource.onerror = function (err) {
    console.error("EventSource failed:", err);
    eventSource.close();
  };
})
const scrollMsgDivToBottom = () => {
  if (msgDiv.value) {
    // 检查内容高度是否超过可见高度
    if (msgDiv.value.scrollHeight > msgDiv.value.clientHeight+5) {
      msgDiv.value.scrollTop = msgDiv.value.scrollHeight;
    }
  }
};
let sendMsg = () => {
  if (d.content && d.content.trim().length > 0) {
    d.canSend = false
    d.userCurrentHistoryContent=d.content
    d.msgs.push({role: 'user', content: d.content})
    http.post('/ai', {msgs: d.msgs, mode: d.mode}).then(res => {
      message.success('success');
      d.content='';
    })
  } else {
    message.warn('The input cannot be empty !')
  }

}
const headerStyle: CSSProperties = {
  textAlign: 'center',
  color: '#fff',
  height: 64,
  paddingInline: 50,
  lineHeight: '64px',

};

const contentStyle: CSSProperties = {
  textAlign: 'center',
  minHeight: 120,
  backgroundColor: '#ffffff',
};


const footerStyle: CSSProperties = {
  textAlign: 'center',
  color: 'lightgray',
  backgroundColor: '#ffffff',

};
</script>
<style lang="less">
.cm-ai {
  .logo-div {
    line-height: 50px;
    font-weight: bold;
    font-size: 20px
  }


  .sider {
    text-align: center;
    height: 100vh;
    background-color: #f9fbff;
    width: 300px;
  }

  .ant-card-body {
    padding: 2px 2px 6px 2px;
  }

  .ant-input {
    border-radius: 6px;
    border: none;
    //border: solid red 1px;
  }

  // 去掉 textarea 聚焦时的边框
  .ant-input:focus {
    border-color: transparent;
    box-shadow: none;
  }
}

</style>

8、启动前后端,进行联调:

发现是可以的,灰色部分是深度DeepSeek R1的思考过程,黑色字是正式回答:

但是我们发现了一个警告:

这个是后端异步调用大模型超时的问题,解决办法,在yml文件中增加配置:增加异步超时时间为1小时,重新启动

server: port: 80 # 端口改为80,方便开发 spring: mvc: async: request-timeout: 3600000 # 1h 超时时间为1个小时

看来还是比较聪明的,真是个大聪明,希望能帮助到你。

第四章:项目打包、外置API-KEY。

项目写完了,需要打包,并且API-KEY写到代码中写死,很不科学,如果要给别人使用,别人要用自己的API-KEY,又不修改代码,那么API-KEY就需要从配置文件读取。

于是在application.yml文件中增加配置名称:

server:
  port: 80 # 端口改为80,方便开发
spring:
  mvc:
    async:
      request-timeout: 3600000 # 1h
apiKey: '' # 启动的时候指定 apiKey 自定义的名称

1、修改AIController代码:

@Value("${apiKey:}")
String apiKey;
// 生成大模型的参数
private  GenerationParam buildGenerationParam(AiReq aiReq) {
    return GenerationParam.builder()
            // 若没有配置环境变量,请用百炼API Key将下行替换为:.apiKey("sk-xxx")
            .apiKey(apiKey)
            .model(aiReq.getMode())
            .messages(aiReq.getMsgs())
            .resultFormat(GenerationParam.ResultFormat.MESSAGE)
            .incrementalOutput(true)
            .build();
}

2、把前端打包后的产物放到src/main/resources/templates目录下

3、启动项目自动打开浏览器的localhost地址:

修改主启动了:

package com.ai.aiadmin;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

import java.io.IOException;

@SpringBootApplication
public class AiAdminApplication {

    public static void main(String[] args) {
        SpringApplication.run(AiAdminApplication.class, args);
        try {
            Runtime.getRuntime().exec("rundll32 url.dll,FileProtocolHandler http://localhost" );
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

4、打包项目:

5、构建产品

在桌面新建一个文件夹,比如ai-app

在文件夹中放入jdk21,放入刚才大号的Spring Boot jar包:

在写一个config.perperties文件,里边编写:

像这样:

6、编写启动脚本:start-app.bat

set aiapp=AiApp.jar

for /f "tokens=1" %%i  in  ('.\jdk-21\bin\jps -l ^| findstr %aiapp%') do (
  taskkill /F /PID %%i
)

:: 读取 config.properties 文件中的 apiKey 值
for /f "tokens=2 delims==" %%a in ('findstr /b "apiKey=" config.properties') do set apiKey=%%a

:: 打印获取到的 apiKey 值
echo 获取到的 apiKey 值: %apiKey%

chcp 65001
.\jdk-21\bin\java -jar  %aiapp% --apiKey=%apiKey%

pause

其实源码已经展示完了,粘贴就行,如果想需要完整项目的,call我吧。

7、双击脚本启动试试:

8、自动打开聊天对话窗口:

9、把产品文件夹压缩,发给别人使用吧。

注意:如果不想别人使用你的API-KEY,那就把config.properties文件中的值删除再发给别人

;