feat(chat): implement WebSocket message sending and integrate with chat state machine

This commit is contained in:
2026-06-16 16:50:57 +08:00
parent 5764b3c433
commit 2792d0c9c5
2 changed files with 152 additions and 11 deletions
+82 -1
View File
@@ -17,7 +17,7 @@
import { fromPromise, fromCallback } from "xstate";
import { createChatWebSocket } from "@/core/net/chat-websocket";
import { createChatWebSocket, ChatWebSocket } from "@/core/net/chat-websocket";
import { Result } from "@/utils/result";
import {
@@ -30,6 +30,28 @@ import {
} from "./chat-machine.helpers";
import type { ChatEvent } from "./chat-events";
// ============================================================
// 共享 WS 引用(chatWebSocketActor ↔ sendMessageWsActor
// ============================================================
/**
* 活跃 ChatWebSocket 单例引用(module-level)。
* - `chatWebSocketActor` 在 connect 后写入、cleanup 时清空
* - `sendMessageWsActor` 读取并调 `ws.sendMessage(content)`
*
* 为什么用 module-level 而非 context.wsRef
* 1) `ChatWebSocket` 是带回调 + 重连 timer 的非可序列化对象,放进
* XState context 会污染快照、警告 non-serializable
* 2) userSession 同一时间只可能有一个活跃 WSparent-level invoke),
* module 单例的语义和"userSession 内一个活跃连接"一一对应
* 3) 不需要重构 ChatWebSocket 类的对外 API
*/
let activeChatWebSocket: ChatWebSocket | null = null;
/** 暴露给 `sendMessageWsActor` 读取(测试也可 import */
export function getActiveChatWebSocket(): ChatWebSocket | null {
return activeChatWebSocket;
}
// ============================================================
// Init 任务 1:拉游客配额(fromPromise,纯 local
// ============================================================
@@ -196,6 +218,13 @@ export const chatWebSocketActor = fromCallback<ChatEvent, { token: string }>(
console.log("[chat-machine] chatWebSocketActor createChatWebSocket DONE", {
wsType: ws.constructor.name,
});
// 写入 module-level 共享引用 —— sendMessageWsActor 借此调 ws.sendMessage()
activeChatWebSocket = ws;
console.log("[chat-machine] chatWebSocketActor activeChatWebSocket SET", {
isConnected: ws.isConnected,
});
ws.onConnected = (userId) => {
console.log("[chat-machine] WS onConnected", {
userId,
@@ -234,10 +263,62 @@ export const chatWebSocketActor = fromCallback<ChatEvent, { token: string }>(
console.log(
"[chat-machine] chatWebSocketActor CLEANUP (parent state exit / logout / cross-transition / unmount)",
);
// 清空 module-level 引用 —— 严格用 `=== ws` 判等,避免覆盖更新的实例
if (activeChatWebSocket === ws) {
activeChatWebSocket = null;
console.log("[chat-machine] chatWebSocketActor activeChatWebSocket CLEARED");
}
ws.disconnect();
};
},
);
// ============================================================
// WebSocket: per-send callback
// ============================================================
/**
* WS 发送消息(userSession.sendingViaWs 期间 invoke
* - 走 `activeChatWebSocket.sendMessage(content)`
* - 成功后 AI 回复通过 `chatWebSocketActor` 的 `onSentence` → `ChatAISentenceReceived` 流回
* - 失败抛错 → state machine 的 `onError` 兜底(清 isReplyingAI、回 ready
*
* 与 sendMessageHttpActor 的区别:
* - sendMessageHttpActor: 一次发送 = 一次返回(HTTP response = AI reply
* - sendMessageWsActor: 一次发送 = 0 次直接返回(WS 异步流式回 replyactor 本身不返 reply
*
* 选 fromPromise 而非 fromCallback
* - 本 actor 是 fire-and-forget —— sync 内同步执行 ws.sendMessage,立即 resolve
* - actor 本身没有"完成"语义(done 来自 WS 事件流,不来自这个 actor)
* - 抛错走 onError 通道
* - fromPromise 的 input type 推断比 fromCallback<void, ...> 稳定(XState v5 已知坑)
*/
export const sendMessageWsActor = fromPromise<void, { content: string }>(
async ({ input }) => {
console.log("[chat-machine] sendMessageWsActor ENTRY", {
contentLength: input.content.length,
contentPreview: input.content.slice(0, 50),
});
const ws = getActiveChatWebSocket();
if (!ws) {
console.error("[chat-machine] sendMessageWsActor no activeChatWebSocket");
throw new Error("[chat-machine] sendMessageWs: no active WebSocket (actor not running)");
}
console.log("[chat-machine] sendMessageWsActor calling ws.sendMessage", {
isConnected: ws.isConnected,
});
const ok = ws.sendMessage(input.content);
if (!ok) {
console.error(
"[chat-machine] sendMessageWsActor ws.sendMessage returned false (not OPEN)",
);
throw new Error("[chat-machine] sendMessageWs: WebSocket not OPEN");
}
console.log(
"[chat-machine] sendMessageWsActor ws.sendMessage OK, awaiting AI sentence stream",
);
// resolve 不返值 —— AI reply 由 chatWebSocketActor 的 ChatAISentenceReceived 流回
},
);
// Re-export `UiMessage` type for the chat-machine.ts public API
type UiMessage = import("@/models/chat/ui-message").UiMessage;
+68 -8
View File
@@ -42,6 +42,7 @@ import {
loadQuotaActor,
loadHistoryActor,
sendMessageHttpActor,
sendMessageWsActor,
loadMoreHistoryActor,
chatWebSocketActor,
} from "./chat-machine.actors";
@@ -69,6 +70,7 @@ export const chatMachine = setup({
loadQuota: loadQuotaActor,
loadHistory: loadHistoryActor,
sendMessageHttp: sendMessageHttpActor,
sendMessageWs: sendMessageWsActor,
loadMoreHistory: loadMoreHistoryActor,
chatWebSocket: chatWebSocketActor,
},
@@ -344,10 +346,23 @@ export const chatMachine = setup({
// userSession(非游客会话 —— WS 父级 + history 子级)
// ========================================================================
userSession: {
// 父级 on:把 WS / AI 流句 事件从 ready.on 上提到 userSession.on
// —— 任何 child stateinitializing / ready / sendingViaWs / sendingViaHttp / loadingMore
// 都能收到 `ChatAISentenceReceived` 并把 AI 句 push 到 messages
// 这点是 sendingViaWs 流式回 reply 的关键(machine 当时在 sendingViaWs
//
// 子级 override 规则:
// - sendingViaWs 重新声明 `ChatWebSocketError` 加 target: "ready"(带 action
// —— XState v5: child handler 替换 parent;复制 action
on: {
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
ChatWebSocketConnected: { actions: "setWsConnected" },
},
initial: "initializing",
exit: "clearWsConnected",
invoke: {
// 父级:WS 长连接(一进来就连,跨 initializing/ready/sending/loadingMore
// 父级:WS 长连接(一进来就连,跨 initializing/ready/sending*/loadingMore
src: "chatWebSocket",
input: ({ event }) => ({
token: event.type === "ChatNonGuestLogin" ? event.token : "",
@@ -385,11 +400,24 @@ export const chatMachine = setup({
},
ready: {
on: {
ChatSendMessage: {
// 发送消息:wsConnected → 走 WS(流式回 reply),否则 → 走 HTTP(一次性回 reply
// 两个 branch 都有 "content 非空" guard(仅作 fallback 兜底:wsConnected=true 也需
// 内容非空;wsConnected=false 同理)
ChatSendMessage: [
{
// WS 已连 + 内容非空 → 走 WSsendingViaWs 等 AI 流式回 reply
guard: ({ context, event }) =>
context.wsConnected && event.content.trim().length > 0,
actions: "appendUserMessage",
guard: ({ event }) => event.content.trim().length > 0,
target: "sending",
target: "sendingViaWs",
},
{
// WS 未连(或 fallback + 内容非空 → 走 HTTPsendingViaHttp 一次性回 reply
guard: ({ event }) => event.content.trim().length > 0,
actions: "appendUserMessage",
target: "sendingViaHttp",
},
],
ChatSendImage: {
actions: "appendUserImage",
},
@@ -398,13 +426,45 @@ export const chatMachine = setup({
},
ChatLogout: "#chat.idle",
ChatGuestLogin: "#chat.guestSession",
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
ChatWebSocketConnected: { actions: "setWsConnected" },
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
// 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected
// 已上提到 userSession.on,子级不再声明
},
},
sending: {
// WS 发送:invoke sendMessageWsActor 触发一次 send
// 之后 AI 流式回 reply 走 userSession.on 的 ChatAISentenceReceived → appendOrUpdateAISentence
// 当句尾 done: true → isReplyingAI = false → always 跳回 ready
sendingViaWs: {
on: {
// 流式回包中 WS 出错(mid-stream → 加错误泡 + 跳回 ready(不卡在 sendingViaWs
// 这个 child handler 替换 userSession.on 的 ChatWebSocketErrorXState v5 不合并)
// 所以需要重复声明 action: "appendSocketErrorMessage"
ChatWebSocketError: {
target: "ready",
actions: "appendSocketErrorMessage",
},
},
invoke: {
src: "sendMessageWs",
input: ({ event }) => ({
content: event.type === "ChatSendMessage" ? event.content : "",
}),
onError: {
// sendMessageWsActor 自身抛错(无 active ws / ws not OPEN → 回 ready + 清 typing
target: "ready",
actions: assign({ isReplyingAI: false }),
},
},
// 监听 isReplyingAI —— appendOrUpdateAISentence 在 done: true 时设 false
always: [
{
target: "ready",
guard: ({ context }) => !context.isReplyingAI,
},
],
},
// HTTP 发送:一次发送 = 一次返回(保留原 sending 状态语义,guest 与 ws-down fallback 走此)
sendingViaHttp: {
invoke: {
src: "sendMessageHttp",
input: ({ event }) => ({