From 2792d0c9c58ff72b7cdd0906d1eaf55d204eeeb1 Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 16 Jun 2026 16:50:57 +0800 Subject: [PATCH] feat(chat): implement WebSocket message sending and integrate with chat state machine --- src/stores/chat/chat-machine.actors.ts | 83 +++++++++++++++++++++++++- src/stores/chat/chat-machine.ts | 80 +++++++++++++++++++++---- 2 files changed, 152 insertions(+), 11 deletions(-) diff --git a/src/stores/chat/chat-machine.actors.ts b/src/stores/chat/chat-machine.actors.ts index df910fb9..d5c7bbb7 100644 --- a/src/stores/chat/chat-machine.actors.ts +++ b/src/stores/chat/chat-machine.actors.ts @@ -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 同一时间只可能有一个活跃 WS(parent-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( 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( 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 异步流式回 reply,actor 本身不返 reply) + * + * 选 fromPromise 而非 fromCallback: + * - 本 actor 是 fire-and-forget —— sync 内同步执行 ws.sendMessage,立即 resolve + * - actor 本身没有"完成"语义(done 来自 WS 事件流,不来自这个 actor) + * - 抛错走 onError 通道 + * - fromPromise 的 input type 推断比 fromCallback 稳定(XState v5 已知坑) + */ +export const sendMessageWsActor = fromPromise( + 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; diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index e56d66a5..8e6b6816 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -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 state(initializing / 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: { - actions: "appendUserMessage", - guard: ({ event }) => event.content.trim().length > 0, - target: "sending", - }, + // 发送消息:wsConnected → 走 WS(流式回 reply),否则 → 走 HTTP(一次性回 reply) + // 两个 branch 都有 "content 非空" guard(仅作 fallback 兜底:wsConnected=true 也需 + // 内容非空;wsConnected=false 同理) + ChatSendMessage: [ + { + // WS 已连 + 内容非空 → 走 WS(sendingViaWs 等 AI 流式回 reply) + guard: ({ context, event }) => + context.wsConnected && event.content.trim().length > 0, + actions: "appendUserMessage", + target: "sendingViaWs", + }, + { + // WS 未连(或 fallback) + 内容非空 → 走 HTTP(sendingViaHttp 一次性回 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 的 ChatWebSocketError(XState 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 }) => ({