Taro+ React + Ts 开发一个ChatGPT的微信小程序

491 阅读3分钟

一 React中使用WebSocket进行实时通讯,TS封装一个简易WebSocket(用SSE更好)

1.封装useWebSocket

import Taro from '@tarojs/taro';
import { useEffect, useRef, useState } from 'react';

//消息类型,下面的代码我用any类型,不建议用any,大家可以自己声明Message类型,为了方便我暂时用any 不用Message

type Message = {
    type: string;
    data: any;
};

type WebSocketOptions = {
    url: string;
    onmessage?: (message: any) => void;
    reconnectInterval?: number;
    reconnectAttempts?: number;
};

const defaultOptions: Required<WebSocketOptions> = {
    url: '',//连接的长链接
    onmessage: () => { },//消息
    reconnectInterval: 1000,//重连时长设置
    reconnectAttempts: Number.MAX_VALUE,//最大连接范围数
};

const useWebSocket = (
    options: WebSocketOptions,
): [WebSocket | undefined, (message: any) => void, any, boolean] => {
    const { url, reconnectInterval, reconnectAttempts, onmessage } = {
        ...defaultOptions,
        ...options,
    };

    const [isConnected, setIsConnected] = useState(false);//是否连接
    const [reconnectCount, setReconnectCount] = useState(0);//用于判断重连
    const [lastMessage, setLastMessage] = useState<any>();//最新的消息

    const socketRef = useRef<any>();
    const reconnectTimerRef = useRef<any>();

    const connect = () => {
        //连接函数封装
        setIsConnected(false);

        Taro.connectSocket({
            url: url,
            header: {
                'content-type': 'application/json'
            },
            success: function () {
                console.log('connect success')
            }
        }).then(task => {
            socketRef.current = task
            task.onOpen(function () {
                console.log('WebSocket is connected');
                setIsConnected(true);
                setReconnectCount(0);

            })

            task.onMessage(function (event) {
                console.log(event)
                const message: any = JSON.parse(event.data);
                console.log(`WebSocket received message: ${message}`);
               setLastMessage(message)
                onmessage(message)
            })
            task.onError(function (event) {
                //异常问题
                console.error('WebSocket error:', event);
            })
            task.onClose(function (event) {
                //连接关闭
                console.error(`WebSocket closed with code ${event.code}`);
                setIsConnected(false);
                if (reconnectCount < reconnectAttempts) {
                    //用于判断断开连接后重新连接
                    reconnectTimerRef.current = setTimeout(() => {
                        setReconnectCount((prevCount) => prevCount + 1);
                        // connect();
                    }, reconnectInterval);
                }
            })

        })
    };

    useEffect(() => {
        connect();
        return () => {
            socketRef.current?.close();
            clearTimeout(reconnectTimerRef.current);
        };
    }, []);

    const sendMessage = (message: any) => {
        console.log(socketRef.current)
        //用于发送消息
        if (isConnected && socketRef.current) {
            console.log(`WebSocket sending message: ${JSON.stringify(message)}`);
            socketRef.current.send({
                data: JSON.stringify(message)
            })
        } else {
            console.error('Cannot send message - WebSocket is not connected');
        }
    };
    //暴露出我们需要的
    return [socketRef.current, sendMessage, lastMessage, isConnected];
};

export default useWebSocket;


2.在项目中引入

const [webSocket, sendMessage, lastMessage, isConnected] = useWebSocket({
    url: 'wss://xxxxxxx',  //这里放长链接 ,
    onmessage: (message) => {
      setUserMessage('')
      setMessage(e => {
        const last = e.pop();
        if (last.role === 'robot') {
          last.text = last.text + message.content
          return e.concat([last])
        } else {
          const robotItem = { text: message.content, role: 'robot', type: 'text' };
          return e.concat([last, robotItem])
        }
      })
      if (message.status === 2) {
        message.solutionVO && setMessage(e => e.concat(
          { type: 'component', role: 'robot', render: <NewMessage name={userMessage} solutionList={message.solutionVO}></NewMessage> },
        ))
        setThinking(false)
      } else {
        setThinking(true)
      }
    }
  });

3. 使用方法和说明

  1. 发送消息是用sendMessage
//和后端约定好消息发送格式,一般需要登陆的话可以先发送一个用户登陆的消息,用作标识
sendMessage({}); 
  1. 最新消息可以直接在lastMessage中拿到
  2. isConnected用于判断是否连接
  3. webSocket是暴露出来的整体,你可以我们封装下的所有方法

注:正常来说需要一个心跳来保持,这个需要和后端进行沟通,前端需要隔多少秒发送一个ping,来检测连接是否正常,否则立马重连

二 实现 ChatGPT 聊天打字兼自动滚动效果

1.如何实现视图跟随内容滚动

首先如何实现聊天界面?其实每一个消息都是一个Message,所有的Message保存在messageData中,将messageData遍历即可实现,如下所示:

    <ScrollView
        id="viewCommunicationBody"
        scrollWithAnimation
        scrollY
        scrollTop={scrollTop}
        scrollIntoView={toView}
      >
          { 
            <View id="viewCommunInner">
              messageData.map((el, i) => <Message id={`item-${i}` text={el.text} />)
            </View>
          }
     </ScrollView>

这里用了ScrollView的scrollIntoView跟scrollTop来实现视图跟随内容滚动

image.png

在每次messageData增加了一条最新的数据让ScrollView滚动到最新的Message

  useEffect(() => {
    messageData && setToView(`item-${messageData.length - 1}`)
  }, [messageData])

但是只是这样会有一个问题,那就是当Message中的文本太长将高度撑高,会出现最后一个Message部分文字被遮挡,这个时候就需要用scrollTop了

 const handleScollTop = () => {
    const query = Taro.createSelectorQuery()
    query.select('#viewCommunicationBody').boundingClientRect()
    query.select('#viewCommunInner').boundingClientRect()
    query.exec((res) => {
      if (res) {
        const scrollViewHeight = res[0].height
        const scrollContentHeight = res[1].height
        if (scrollContentHeight > scrollViewHeight) {
          const scrollTop = scrollContentHeight - scrollViewHeight + 220
          setScrollTop(scrollTop)
        }
      }
    })
  }

想让 scroll-view 一直滚动到底部,只需要让 scroll-top 等于 scroll-view 内容高度减去 scroll-view 容器本身高度就可以了

2.打字效果实现

在上文封装的useWebSocket中可以获取到webSockect每次返回的数据,将每次返回的数据拼接再传入Message中就可以实现打字机效果了

  const [messageData, setMessage] = useState<any[]>([])
  const [webSocket, sendMessage, lastMessage, isConnected] = useWebSocket({
    url: 'wss://xxxxxxxxx',  //这里放长链接 ,
    onmessage: (message) => {
    // 这里可以获取到webSockect每次返回的数据
      setMessage(e => {
        const last = e.pop();
        // 将获取到的数据拼接
        if (last.role === 'robot') {
          last.text = last.text + message.content
          return e.concat([last])
        } else {
        // 传入Message组件
          const robotItem = { text: message.content, role: 'robot', type: 'text' };
          return e.concat([last, robotItem])
        }
      })
    }
  })  

三 使用第三方插件微信同声传译将语音转文字,文字转语音

1.微信公众平台添加插件:微信同声传译

image.png

2.配置

在app.config中配置(version跟provider对应插件最新版本号跟AppID)

  plugins: {
    "WechatSI": {
      "version": "0.0.7",
      "provider": "wx069ba97219f66d99"
    }
  }

3.封装useRecorder

import Taro, { requirePlugin, useDidHide } from "@tarojs/taro";
import { useEffect, useRef, useState } from "react";

interface RecorderProps {
    text: string
}

export function useRecorder(props: RecorderProps) {
    const { text } = props
    const [currentText, setCurrentText] = useState<string>('')
    const innerAudioContext = useRef<any>()
    const plugin = requirePlugin("WechatSI")
    const recorderManager = plugin.getRecordRecognitionManager()
    recorderManager.onRecognize = function (res) {
        console.log("current result", res.result)
    }
    recorderManager.onStop = function (res) {
        console.log("record file path", res.tempFilePath)
        console.log("result", res.result)
        setCurrentText(res.result)
    }
    recorderManager.onStart = function (res) {
        console.log("成功开始录音识别", res)
    }
    recorderManager.onError = function (res) {
        console.error("error msg", res.msg)
    }
    // 文字转语音
    const playTextToVoice = () => {
        //创建内部 audio 上下文 InnerAudioContext 对象。
        innerAudioContext.current = Taro.createInnerAudioContext();

        plugin.textToSpeech({
            // 调用插件的方法
            lang: 'zh_CN',
            // lang: ‘en_US’,
            content: text,
            success: function (res) {
                playAudio(res.filename);
            }
        });
    }
    // 播报语音
    const playAudio = (e) => {
        console.log(e, innerAudioContext)
        innerAudioContext.current.src = e; //设置音频地址
        innerAudioContext.current.play(); //播放音频
    }

    useDidHide(() => {
        console.log('end')
        innerAudioContext && innerAudioContext.current.stop();
        innerAudioContext && innerAudioContext.current.destroy();
    })

    return { recorderManager, currentText, playTextToVoice, }
}

4.使用

import { Button, View } from '@tarojs/components'
import Taro from '@tarojs/taro';
import styles from './index.module.less'

import { useRecorder, } from '@/hooks/auth/useRecorder';
import { useEffect, useState } from 'react';

export default function index() {
    const { recorderManager, currentText, playTextToVoice } = useRecorder(
    {text:'这里传想要合成得语音'}
    )
 
    const handleClick = () => {
        recorderManager.start({ duration: 30000, lang: "zh_CN" })
    };

    const handleComplete = () => {
        recorderManager.stop(); // 停止录音
    };

    return (
        <View className={styles.page}>
            <Button className={styles.button} onTouchStart={handleClick} onTouchEnd={handleComplete}>开始录音</Button>
            <Button className={styles.button} onClick={playTextToVoice}>播放合成语音</Button>
            <View>语音识别内容:{currentText}</View>
        </View>
    )
}

参考

juejin.cn/post/722702…

juejin.cn/post/724619…