1

前言

最近全网最火的应该就是ChatGPT了,于是我也想把ChatGPT集成到我的网站中,但是由于局域网的原因,于是我找了个替代方案:通义千问文心一言

准备

前端我使用的是Next.js13后台使用的是SpringBoot3,下面列举了版本如下所示:

"next": "^13.4.19"
<properties>
    <java.version>17</java.version>
    <spring-boot.version>3.0.2</spring-boot.version>
    <spring-cloud.version>2022.0.0</spring-cloud.version>
    <spring-cloud-alibaba.version>2022.0.0.0-RC2</spring-cloud-alibaba.version>
</properties>

在开始之前我列举我们将要集成通义千问的整个思路及遇到的问题和解决方案。

1、通义千问SDK集成
当然你也可以使用HTTP的方式,也就是链接直接请求接口,这个是通义千问的API文档,还有就是实现多轮会话效果,聊天并不是回答完下次再问就不知道上次你问或者生成的结果了。

2、后台流式返回AI自成的数据流
流式返回是重点,我参考了好多博客和查看了通义千问还有文心一言无不是使用流式返回,并且后台必须设置请求头为Content-Type:text/event-stream

3、前端使用fetch接收AI生成的数据
接收Content-Type:text/event-stream返回的数据我们不能使用普通场景下的请求及处理方式了,还有就是前端如何漂亮的显示代码高亮及复制代码?这也是需要我们去实现的。

4、前端实现光标跟随文字生成效果
用过chatgpt的同学都知道,生成的文字后面会跟着一个光标,这个也是我们需要实现的。

5、前端实现聊天列表分页、移动端适配、手动停止接口返回数据
我想大家也是前端高手,就算不是在网上也能找到相关的代码,这也不是我们需要关注的重点,所以我就不详细讲了。

后台操作

上面提供了思路和要解决的问题,下面我们就先从后台开始讲解如何集成AI。

1、添加SDK

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>dashscope-sdk-java</artifactId>
    <version>2.7.0</version>
</dependency>

2、定义消息类

@Setter
@Getter
public class ChatAiMessage {
    private String id;

    private String chatAiId;

    private String uid;

    // 聊天内容
    private String message;

    // 聊天类型:user bot
    private String type;

    private Date createdDt;//创建时间
    private Date updatedDt;//更新时间
}

大家可以根据自己的需要定义实体类,我这个仅供参考

3、实现AI流式多轮会话接口

下面是实现的代码:

 @PostMapping(value = "/crud/chatAi/pull", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public ResponseEntity<?> pull(@RequestBody ChatAiRequest chatAiRequest) {
        // 获取通义千问控制台设置的API key 
        Constants.apiKey = aliyunConfig.getDashscopeApiKey();
        try {
            String chatAiId = chatAiRequest.getChatAiId();

            if (!StringUtils.hasLength(chatAiId)) {
                return ResponseEntity.ok(new ResultInfo<>(ResultStatus.DATA_EMPTY));
            }
            Generation gen = new Generation();

            List<Message> messages = new ArrayList<>();
            // 根据chatAiId查询前10条数据
            List<ChatAiMessage> chatAiMessages = chatAiMessageService.queryMessage(chatAiId);

            //  恢复成正序
            Collections.reverse(chatAiMessages);

            // 判断返回的第1个元素type如果不是user,则删除
            // 注意:第1条消息必须是user或者system
            ChatAiMessage firstMessage = chatAiMessages.get(0);
            if (firstMessage.getType().equals("bot")){
                chatAiMessages.remove(0);
            }

            // 添加最近20条历史聊天记录
            if (chatAiMessages.size() > 0) {
                chatAiMessages.forEach(message -> {
                    if (message.getType().equals("user")) {
                        Message userMsg = Message
                                .builder()
                                .role(Role.USER.getValue())
                                .content(message.getMessage())
                                .build();
                        messages.add(userMsg);
                    }
                    if (message.getType().equals("bot")) {
                        Message assistantMsg = Message
                                .builder()
                                .role(Role.ASSISTANT.getValue())
                                .content(message.getMessage())
                                .build();
                        messages.add(assistantMsg);
                    }
                });
            }

            QwenParam param = QwenParam.builder().model(Generation.Models.QWEN_PLUS).messages(messages)
                    .resultFormat(QwenParam.ResultFormat.MESSAGE)
                    .topP(0.8)
                    .enableSearch(true)
                    // get streaming output incrementally
                    .incrementalOutput(true)
                    .build();

            Flowable<GenerationResult> result = gen.streamCall(param);

            HttpHeaders headers = new HttpHeaders();
            // 设置成text/event-stream形式
            headers.setContentType(MediaType.TEXT_EVENT_STREAM);

            return new ResponseEntity<>(result, headers, HttpStatus.OK);
        } catch (Exception ex) {
            log.error("chat ai pull error ={}", ex.getMessage());
            return ResponseEntity.ok(new ResultSuccess<>(false));
        }
    }

现在我们着重讲解下上面这段代码的主要含义:

String chatAiId = chatAiRequest.getChatAiId();

List<ChatAiMessage> chatAiMessages = chatAiMessageService.queryMessage(chatAiId);

Collections.reverse(chatAiMessages);

首先后台获取前端返回的chatAiId参数,并根据这个chatAiId查询最新交流的10条数据(这个数量大家可以根据自己的需要设置),目的是实现多轮会话(不需要前端带回),并且把查询回来的列表数据给反转一下,目的也是设置成聊天正序。

List<Message> messages = new ArrayList<>();

// 判断返回的第1个元素type如果不是user,则删除
// 注意:第1条消息必须是user或者system
ChatAiMessage firstMessage = chatAiMessages.get(0);
if (firstMessage.getType().equals("bot")){
    chatAiMessages.remove(0);
}
if (chatAiMessages.size() > 0) {
    chatAiMessages.forEach(message -> {
        if (message.getType().equals("user")) {
            Message userMsg = Message
                    .builder()
                    .role(Role.USER.getValue())
                    .content(message.getMessage())
                    .build();
            messages.add(userMsg);
        }
        if (message.getType().equals("bot")) {
            Message assistantMsg = Message
                    .builder()
                    .role(Role.ASSISTANT.getValue())
                    .content(message.getMessage())
                    .build();
            messages.add(assistantMsg);
        }
    });
}

接着,我们来构造一下聊天列表给AI SDK调用,最后都添加到messages列表中,这里需要注意一下,因为AI SDK的要求,第1条聊天记录的类型必须是System或者User,所以如果不是则删除第1个使其每次第1个元素都是User类型,再有,后面的消息是USERASSISTANT交替出现的。

QwenParam param = QwenParam.builder().model(Generation.Models.QWEN_PLUS).messages(messages)
        .resultFormat(QwenParam.ResultFormat.MESSAGE)
        .topP(0.8)
        .enableSearch(true)
        .incrementalOutput(true)
        .build();

Flowable<GenerationResult> result = gen.streamCall(param);

HttpHeaders headers = new HttpHeaders();
// 设置成text/event-stream形式
headers.setContentType(MediaType.TEXT_EVENT_STREAM);

return new ResponseEntity<>(result, headers, HttpStatus.OK);

最后我们将上面构造的messages放到QwenParam中并做相关配置,返回了流式数据并以text/event-stream形式返回到前端,这样就完成了流式接口的编写,我们可以通过POSTMAN试试看看接口成不成功。
image.png

接口返回数据正常,下面我们来设置前端接收数据。

前端操作

1、处理fetch接口数据

前端我们使用fetch获取接口流式数据,如下所示:

// 设置终止信号
const controller = new AbortController();
setAbortController(controller)
const signal = controller.signal;

// 使用fetch API请求数据流
fetch(`${process.env.NEXT_PUBLIC_API_HOST}/space/crud/chatAi/pull`, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({
            chatAiId: query.chatAiId
        }),
        // 添加这一行才能携带cookie给后台
        credentials: 'include',
        // 将signal传递给fetch请求
        signal: signal
    }
).then((response) => {
    const decoder = new TextDecoder();
    reader = response.body.getReader();
    return reader.read().then(function processText({done, value}) {
        // 当数据流结束时,done会被设置为true
        if (done) {
             //todo:实现自己的业务逻辑       
            return
        }
        // 解码数据并更新状态
        const messages = decoder.decode(value).split('\n');
        messages.forEach(async (message) => {
            if (message) {
                if (message.startsWith('data:') || message.startsWith('{"')) {
                    let parseData = ''
                    if (message.startsWith('data:')) {
                        const repData = message.replace('data:', '')
                        if (repData) {
                            parseData = JSON.parse(repData);
                        }
                    } else {
                        parseData = JSON.parse(message)
                    }
                    if (parseData) {
                        const {requestId, output, usage} = parseData
                        if (output) {
                            const {text, finishReason, choices} = output
                            if (choices.length > 0) {
                                if (choices[0].finishReason === 'stop') {
                                    //todo:实现自己的业务逻辑  
                                } else {
                                    const {content, role} = choices[0].message
                                    //todo:实现自己的业务逻辑  
                                }
                            }
                        }
                    }
                }
            }
        });

        // 读取并处理下一块数据
        return reader.read().then(processText);
    });
})

上面代码就是告诉你如何从后台接口返回的数据中读取内容,并解析出我们想要的信息,因为后台返回的接口格式是下面这样的,所以我们要拆解开并拿到具体AI生成的消息,也就是里面的content内容。

image.png

拿到消息之后,我们就可以显示消息到前端页面了,当然你可以使用AbortController来终止fetch接口请求。

const controller = new AbortController();
abortController.abort()

2、实现代码高亮和复制效果

做为程序员平时使用AI时的场景也是非常多的,但是AI生成的代码内容如下所示:
image.png

虽然也能看,但是跟我们使用IDE上面查看的代码样式上面相比让人觉得难看,也不易阅读,而且我们还要实现复制代码功能,这样可以提高开发效率,于是我们可以着手改造下前端显示代码高亮以及复制效果。我在网上找到两篇文章ChatGPT接口返回代码高亮显示的实现逻辑使用marked和highlight.js对GPT接口返回的代码块渲染,高亮显示,支持复制,和选择不同的高亮样式,并参考了一下实现方式,下面是具体的实现代码:

安装相关包依赖:

"clipboard": "^2.0.11",
"@traptitech/markdown-it-katex": "^3.6.0",
"highlight.js": "^11.9.0",
"markdown-it": "^13.0.2",

实现代码:

import mdKatex from '@traptitech/markdown-it-katex'
import MarkdownIt from 'markdown-it'
import "highlight.js/styles/github-dark.min.css";
import ClipboardJS from 'clipboard';

const mdi = new MarkdownIt({
    linkify: true,
    highlight(code, language) {
        const validLang = !!(language && hljs.getLanguage(language))
        if (validLang) {
            const lang = language ?? ''
            return highlightBlock(hljs.highlight(lang, code, true).value, lang)
        }
        return highlightBlock(hljs.highlightAuto(code).value, '')
    }
})

mdi.use(mdKatex, {blockClass: 'katexmath-block rounded-md p-[10px]', errorColor: ' #cc0000'})


function highlightBlock(str, lang) {
    const codeIndex1 = parseInt(Date.now() + "") + Math.floor(Math.random() * 10000000);
    const codeIndex2 = parseInt(Date.now() + "") + Math.floor(Math.random() * 10000000);

    const clipboard = new ClipboardJS(`#copy-${codeIndex2}`);
    // 复制成功失败的提示
    clipboard.on("success", (e) => {
        message.success("复制成功");
    });
    clipboard.on("error", (e) => {
        message.error("复制失败");
    });

    return `<pre class="pre-code-box"><div class="pre-code-header"><span class="code-block-header__lang">${lang}</span><span id="copy-${codeIndex2}" class="code-block-header__copy" data-clipboard-action="copy" data-clipboard-target="#copy${codeIndex1}">复制代码</span></div><div class="pre-code"><code id="copy${codeIndex1}" class="hljs code-block-body ${lang}">${str}</code></div></pre>`
}

const getMdiText = (value) => {
    return mdi.render(value)
}

<div dangerouslySetInnerHTML={{__html: getMdiText(chat.message)}}>
</div>

上面代码我找几个关键点说明一下,getMdiText方法将AI生成的消息传递过去,并使用new MarkdownIt来实现高亮操作,由于我们已经引入了代码高亮样式highlight.js/styles/github-dark.min.css,所以最终会呈现下面的样式,然后我们在highlightBlock方法中添加代码块的头部样式,目的就是为了实现复制功能,codeIndex1codeIndex2是生成唯一的ID。

new ClipboardJS(`#copy-${codeIndex2}`)

<span id="copy-${codeIndex2}" class="code-block-header__copy" data-clipboard-action="copy" data-clipboard-target="#copy${codeIndex1}">复制代码</span>

<code id="copy${codeIndex1}" class="hljs code-block-body ${lang}">${str}</code>

注意上面的代码,我们在ClipboardJS中注入了#copy-${codeIndex2},并且这个命名和下面的id="copy-${codeIndex2}"保持一致,目的就是监听复制(因为AI聊天内容可能有多个代码块,所以我们使用codeIndex2区别就是这个原因)。

最后就是data-clipboard-target注入了#copy${codeIndex1},并且这个命名和下面的<code id="copy${codeIndex1}">保持一致,目的也是告诉ClipboardJS我要复制是哪段代码。

这样就完成了高亮和复制功能,下面看看实现效果:

image.png

3、实现光标追随文字效果

实现方式思路是这样的,我们现在实例化一段代码来说明下,如下所示:

<div class="container">
    <div class="content">开始xxxxxxxx结束</div>
</div>

我现在问大家一个问题,如果是你,你会把光标放在container内,还是content内?给大家3秒思考时间!

是的,我们应该把光标放到content内,而且是最末位,每生成一个字符都会占住最后一个位置,有了思路接下来我们如何实现呢?这里我们使用:after伪元素来实现,代码如下所示:

@keyframes cursor {
  0% {
    opacity: 0;
  }
  50% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}


.container{
    &:last-child{
    // 如果ai输出结束了,刚不显示伪元素after
    .stop > *:last-child{
      &:after{
        content: none !important;
      }
    }
    // 获取最后一个子节点并设置光标跟随
    .content > *:last-child {
      position: relative;
      &:after{
        display: inline-block;
        content: "";
        width: 4px;
        height: 14px;
        transform: translate(4px,2px) scaleY(1.3);
        background-color: #80c9ff;
        animation: cursor .6s infinite;
        overflow-wrap: break-word;
        box-sizing: border-box;
      }
    }
  }
}

上面代码我们定义了cursor动画,并且应用到content最后一个节点中的:after伪元素实现光标闪烁效果,并且当AI生成结束之后,我们通过.stop来关闭:after

这还没完,因为当AI开始生成的时候.content里面的内容是空空如也,这时候为了好看,我们可以也显示光标就像命令行一样等待用户输入一样,于是我们加上下面代码:

// 节点空时设置光标跟随
  .chat:empty{
    position: relative;
    &:after{
      display: inline-block;
      content: "";
      width: 4px;
      height: 14px;
      transform: translate(4px,2px) scaleY(1.3);
      background-color: #80c9ff;
      animation: cursor .6s infinite;
      overflow-wrap: break-word;
      box-sizing: border-box;
    }
  }

这样当内容为空时也显示光标了,而当content中有内容时也不会显示,就不会出现两个光标情况了。

下面我们看看最终显示效果:

内容为空时显示:
image.png

内容不为空时显示:
image.png

图片最后一行就是要显示的光标,说明都成功了,最终效果图如下所示!
image.png

总结

1、首先是后台的返回,使用的是text/event-stream返回数据,而前端如何接收是个难点。
2、实现代码高亮和复制我是参考网上大佬的博客,上面已经给出了。
3、实现光标文字追随是最有意思的,大家可以自己实现看看。

引用

React18的useEffect会执行两次
使用marked和highlight.js对GPT接口返回的代码块渲染,高亮显示,支持复制,和选择不同的高亮样式
五分钟实现一个chatGPT打字效果
highlightjs demo
ChatGPT API SSE(服务器推送技术)和 Fetch 请求 Accept: text/event-stream 标头案例
如何搭建属于自己的ChatGPT应用(三)通过Axios+EventSource使用GPT3.5 API
在 React 中使用 highlight.js 和 Clipboard.js 实现代码高亮和复制功能

本文参与了SegmentFault 思否写作挑战赛活动,欢迎正在阅读的你也加入。

Awbeci
3.1k 声望212 粉丝

Awbeci