封装 WebSocket ,切图仔站上 GPT 风口

GPT 出世半年了,一个个『大模型』如雨后春笋般不断涌现。这场春雨能否滋润『已死』的前端呢?

个人认为 GPT 为软件点亮了新的技能点,对前端会产生『不大』的影响。这一点可以类比语音识别自然语言处理图像识别对前端的影响,语音识别+自然语言处理催生了一批『语音输入』的场景;图像识别则催生了一批『扫一扫』、『搜图』的场景。尽管这没有 『端』的变革 (比如智能机、Vision Pro) 对前端影响大,端的开发者也应该与时俱进地了解与 GPT 交互的方式。

闲扯结束,正片开始…

GPT 场景特征

GPT 的『问答式』交互很容易让人想到更熟悉的『聊天室』。两者有很多相同点:

  • 对话内容:多模态。支持文字、图片、语音…
  • 整体交互:发送消息、消息列表、历史记录

03a1f74a462a44dd7044ca0a420331a1.pngimage.png
两者也存在细节的差异。

  • 对话双方:GPT 一方固定是机器人,而聊天室是两个用户
  • 对话机制:GPT 是半双工通信,人提问机器人回答,机器人不会主动说话。而聊天室是全双工通信,双方自由讨论
  • GPT 机器人消息可逐句输出
  • GPT 提问方可打断回答

通信方式

  • WebSocket:参考聊天室很自然的选择,协议基于TCP实现。
    • 全双工通信
    • 不限制传输内容,适合多模态对话
    • 保持连接,服务端可以用『连接』实例对应 GPT 所需对话上下文。
  • SSE:半双工、可『一问多答』,协议基于HTTP实现。
    • 限制条件:请求传参纯文本、服务端维护会话上下文、HTTP1.1浏览器的连接数限制

后文只介绍WebSocket的实现

WebSocket 封装

WebSocket 并不限制通信双方传输的内容,双方消息都以纯文本传输

张三李四问个问题你说推荐几个北京特色美食我想想张三李四

不加规范的纯文本会产生歧义,服务端收到『STOP』可以认为这是一个用户消息,也可以认为是『停止生成』的指令。

张三gpt帮我写一篇日记今天阳光明媚。STOP我不太明白你的意思张三gpt

因此产生了各式各样的封装,比如stompjs使用类似的封装意味着服务端也要实现其中定义的规范。 在问答场景中就大材小用了。

这里选取JSON形式封装消息,通信中双方仅互认符合JSON规范的内容。eventType 用于描述指令,比如 SEND(提问)、ACK(确认)、STOP(打断);body类似 HTTP 消息体,承载消息内容。

{
  eventType: '指令名',
  body: any
}

为了减少 WebSocket 连接数,我希望一次页面访问同一时刻只维护一个 WebSocket 链接。

let ws: WebSocket | null = null;











const chat = {



    // 建立连接
    open() {},
    // 断开连接
    close() {},
    // 发送消息
    send() {},
    // 重新生成
    rebuild() {},
    // 停止生成
    stop() {},
};

open/close

断开连接时调用原生的 close 后释放实例即可。

let ws: WebSocket | null = null;











const chat = {



    // ...


    close() {
        ws?.close();
        ws = null;
    },
};

建立连接的过程是异步的,可能产生连接可用和连接失败两个结果,因此返回 Promise 。

let ws: WebSocket | null = null;











const chat = {



    // ...


    open(): Promise<boolean> {
        if (ws) {
            return Promise.resolve(true);
        }
        const protocol = location.protocol.startsWith("https:") ? "wss" : "ws";
        return new Promise((res, rej) => {
            ws = new WebSocket(`${protocol}://${location.host}/ws-chat`);
            onceListener("open")
                .then(() => {
                    // 开启心跳
                    pingCyclically();
                    sendMessage("OPEN", {})
                        .then(() => subscribeOnce("OPEN"))
                        .then(() => res(true))
                        .catch(() => res(false));
                })
                .catch(() => {
                    res(false);
                });
            onceListener("error").finally(() => {
                rej(new Error("连接意外关闭"));
            });
            onceListener("close").finally(() => {
                res(false);
            });
        });
    },
};
  • onceListener: 封装了原生的 addEventListener,只监听最近一次事件触发。
  • sendMessage: 封装了原生的 send ,包装约定的 eventType JSON 结构,并支持消息延迟发送。
  • subscribeOnce: 封装了原生的 addEventListener,只监听最近一次特定的 eventType 的 message 事件触发

心跳机制

在信道空闲的情况下,WebSocket 实例无法实时更新连接状态。 这会导致需要通信时,发送方不能提前感知信道状态。

想象我们打电话时沉默一段时间后问对方『你在听吗』的意图。解决方案是定期向信道内放『噪声』,类似投石问路,这就是心跳机制

let ws: WebSocket | null = null;











let pingTimer: number = 0;
function pingCyclically() {
    clearInterval(pingTimer);
    pingTimer = setInterval(() => {
        if (ws?.readyState === WebSocket.OPEN) {
            sendMessage("OPEN", {});
        } else {
            clearInterval(pingTimer);
        }
    }, 5000);

这里约定了 eventType: OPEN 作为心跳事件。

send

发送消息的会话模式如下:

clientserverSENDACKMESSAGEMESSAGEFINISHERRORclientserver

ACK 是用户提问的确认,当前提问在历史中的结构。MESSAGE 是逐句返回的指令,以 FINISH 指令结尾。生成期间穿插 ERROR 指令表示异常终止。

let ws: WebSocket | null = null;











}
const sendChat = async (
    sentEventType: string,
    data: Record<string, any>,
    cb: (position: string, data: ServerLiveMessage | null, error?: Error) => void
) => {
    const hasSent = await sendMessage(sentEventType, data);
    if (hasSent) {
        let count = 0;
        const currentMessageRemover = subscribe("MESSAGE", (data, _, error) => {
            if (error) {
                cb(count ? "NORMAL" : "FIRST", null, new Error("连接失效"));
            } else {
                cb(count ? "NORMAL" : "FIRST", data);
            }
            count++;
        });
        const endRemover = subscribe(
            /^ERROR|FINISH$/,
            (data, res) => {
                if (res.eventType === "FINISH") {
                    cb("LAST", null);
                } else {
                    cb("LAST", data, new Error("服务异常"));
                }
            },
            true
        );
        try {
            return await subscribeOnce("ACK");
        } catch (e) {
            throw new Error("发送失败");
        }
    }
    throw new Error("发送失败");
};
const chat = {
    // ...
    send: sendChat.bind(null, "SEND"),
};

参考 SEND – ACK 期间的状态返回 Promise ,顺序监听后续的服务端指令,触发外部回调处理消息,

stop

停止生成的会话模式是STOP的一问一答。

clientserverSTOPSTOPclientserver
let ws: WebSocket | null = null;











const chat = {



    // ...


    async stop(data: Record<string, any>) {
        const hasSent = await sendMessage("STOP", data);
        if (hasSent) {
            let data: ServerLiveMessage | null = null;
            try {
                data = await subscribeOnce("STOP");
            } catch (e) {
                throw new Error("发送失败");
            }
            return data;
        }
        throw new Error("发送失败");
    },
};

参考来回两个 STOP 期间的状态返回 Promise。

rebuild

重新生成的会话模式与提问类似,只是提问的指令改为 REBUILD。

clientserverREBUILDACKMESSAGEMESSAGEFINISHERRORclientserver
const chat = {
    // ...
    send: sendChat.bind(null, "REBUILD"),
};

细节处理

一旦触发停止生成,当前正在生成的回答就不应该监听了。可以将前面的 currentMessageRemoverendRemover 推入 eventRemoverList ,适时清除监听。

let eventRemoverList: Array<() => void> = [];
const clearEvent = () => {
    eventRemoverList.forEach((fun) => fun?.());
    eventRemoverList = [];
};

经过封装后,上层业务只需要调用对应的意图,处理消息列表渲染,即可与 GPT 交互。

完整代码

GPT 头脑风暴

  1. 希望保留无 GPT 的选项。鼠标提升了计算机的易用性,但是老手们也需要快捷键。同理,输入自然语言或者语音很易用,但对于意图明确的用户不够高效。
  2. 希望不要用 GPT 取代人工客服。目前的智能机器人还无法缓解人类用户的焦虑。某些平台切换到英文版才能召唤人工客服。
  3. 希望 GPT 能够减少各语言文化圈的隔阂。一个本地化的 GPT 可以成为世界了解当地的向导、语言学习的帮手、翻译阅读的助手。

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYGH9UNC' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片