fix(chat): queue outgoing messages
This commit is contained in:
@@ -27,9 +27,10 @@
|
||||
* - 每个任务都是独立 actor,不互相等待(除非 `always` barrier)
|
||||
*
|
||||
* 消息发送路径(业务事实):
|
||||
* - guestSession:走 HTTP,消息数量限制以后端 `lockDetail` 响应为准
|
||||
* - nonVipUserSession:走 HTTP,让后端返回 daily_limit lockDetail
|
||||
* - vipUserSession + WS 已连:走 WS,不受每日免费次数限制
|
||||
* - `ChatSendMessage` 只负责即时追加用户消息 + 入队,不进入 sending 子状态
|
||||
* - guestSession / nonVipUserSession:队列串行走 HTTP,限制以后端 `lockDetail` 为准
|
||||
* - vipUserSession:队列优先走 WS,WS 不可用时 fallback HTTP
|
||||
* - `pendingReplyCount` 跟踪待回复数量,`isReplyingAI` 由计数派生
|
||||
* - `ChatWebSocketConnected` 事件 → `wsConnected = true`
|
||||
* - `vipUserSession.exit` action → `wsConnected = false`(WS actor cleanup 时也会跑 exit)
|
||||
*
|
||||
@@ -39,7 +40,7 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import { setup, assign } from "xstate";
|
||||
import { setup, assign, sendTo } from "xstate";
|
||||
|
||||
import { todayString, Logger } from "@/utils";
|
||||
|
||||
@@ -47,6 +48,8 @@ import { ChatState, initialState } from "./chat-state";
|
||||
import type { ChatEvent } from "./chat-events";
|
||||
import {
|
||||
applyHttpSendOutput,
|
||||
beginPendingReply,
|
||||
finishPendingReply,
|
||||
normalizeLockDetail,
|
||||
} from "./chat-machine.helpers";
|
||||
import {
|
||||
@@ -55,7 +58,9 @@ import {
|
||||
sendMessageWsActor,
|
||||
loadMoreHistoryActor,
|
||||
chatWebSocketActor,
|
||||
httpMessageQueueActor,
|
||||
unlockPrivateMessageActor,
|
||||
wsPreferredMessageQueueActor,
|
||||
} from "./chat-machine.actors";
|
||||
|
||||
const log = new Logger("StoresChatChatMachine");
|
||||
@@ -79,9 +84,13 @@ export const chatMachine = setup({
|
||||
sendMessageWs: sendMessageWsActor,
|
||||
loadMoreHistory: loadMoreHistoryActor,
|
||||
chatWebSocket: chatWebSocketActor,
|
||||
httpMessageQueue: httpMessageQueueActor,
|
||||
wsPreferredMessageQueue: wsPreferredMessageQueueActor,
|
||||
unlockPrivateMessage: unlockPrivateMessageActor,
|
||||
},
|
||||
actions: {
|
||||
enqueueMessage: sendTo("messageQueue", ({ event }) => event),
|
||||
|
||||
startGuestSession: assign(() => ({
|
||||
...initialState,
|
||||
})),
|
||||
@@ -114,7 +123,7 @@ export const chatMachine = setup({
|
||||
date: today,
|
||||
},
|
||||
],
|
||||
isReplyingAI: true,
|
||||
...beginPendingReply(context),
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
upgradeHint: null,
|
||||
@@ -142,7 +151,7 @@ export const chatMachine = setup({
|
||||
date: today,
|
||||
},
|
||||
],
|
||||
isReplyingAI: true,
|
||||
...beginPendingReply(context),
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
upgradeHint: null,
|
||||
@@ -168,7 +177,7 @@ export const chatMachine = setup({
|
||||
imageUrl: event.imageBase64,
|
||||
},
|
||||
],
|
||||
isReplyingAI: true,
|
||||
...beginPendingReply(context),
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
upgradeHint: null,
|
||||
@@ -195,7 +204,7 @@ export const chatMachine = setup({
|
||||
imageUrl: event.imageBase64,
|
||||
},
|
||||
],
|
||||
isReplyingAI: true,
|
||||
...beginPendingReply(context),
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
upgradeHint: null,
|
||||
@@ -227,7 +236,17 @@ export const chatMachine = setup({
|
||||
done: event.done,
|
||||
messagesCount: messages.length,
|
||||
});
|
||||
return { messages, isReplyingAI: !event.done };
|
||||
if (!event.done) {
|
||||
return {
|
||||
messages,
|
||||
pendingReplyCount: Math.max(1, context.pendingReplyCount),
|
||||
isReplyingAI: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
messages,
|
||||
...finishPendingReply(context),
|
||||
};
|
||||
}),
|
||||
|
||||
appendSocketErrorMessage: assign(({ context }) => {
|
||||
@@ -239,7 +258,12 @@ export const chatMachine = setup({
|
||||
date: todayString(),
|
||||
},
|
||||
];
|
||||
return { messages, isReplyingAI: false };
|
||||
return { messages, ...finishPendingReply(context) };
|
||||
}),
|
||||
|
||||
applyQueuedHttpOutput: assign(({ context, event }) => {
|
||||
if (event.type !== "ChatQueuedHttpDone") return {};
|
||||
return applyHttpSendOutput(context, event.output);
|
||||
}),
|
||||
|
||||
appendAIImage: assign(({ context, event }) => {
|
||||
@@ -310,7 +334,7 @@ export const chatMachine = setup({
|
||||
|
||||
applyUnlockPrivateOutput: assign(({ context, event }) => {
|
||||
if (!("output" in event)) return {};
|
||||
const output = event.output as {
|
||||
const output = event.output as unknown as {
|
||||
messageId: string;
|
||||
response: import("@/data/dto/chat").UnlockPrivateResponse;
|
||||
};
|
||||
@@ -386,6 +410,10 @@ export const chatMachine = setup({
|
||||
},
|
||||
|
||||
guestSession: {
|
||||
invoke: {
|
||||
id: "messageQueue",
|
||||
src: "httpMessageQueue",
|
||||
},
|
||||
on: {
|
||||
ChatNonVipLogin: {
|
||||
target: "#chat.nonVipUserSession",
|
||||
@@ -395,6 +423,12 @@ export const chatMachine = setup({
|
||||
target: "#chat.vipUserSession",
|
||||
actions: "startUserSession",
|
||||
},
|
||||
ChatQueuedHttpDone: {
|
||||
actions: "applyQueuedHttpOutput",
|
||||
},
|
||||
ChatQueuedSendError: {
|
||||
actions: "appendSocketErrorMessage",
|
||||
},
|
||||
},
|
||||
initial: "initializing",
|
||||
states: {
|
||||
@@ -428,9 +462,8 @@ export const chatMachine = setup({
|
||||
ready: {
|
||||
on: {
|
||||
ChatSendMessage: {
|
||||
actions: "appendGuestUserMessage",
|
||||
actions: ["appendGuestUserMessage", "enqueueMessage"],
|
||||
guard: ({ event }) => event.content.trim().length > 0,
|
||||
target: "sending",
|
||||
},
|
||||
ChatSendImage: {
|
||||
actions: "appendGuestUserImage",
|
||||
@@ -482,6 +515,10 @@ export const chatMachine = setup({
|
||||
},
|
||||
|
||||
nonVipUserSession: {
|
||||
invoke: {
|
||||
id: "messageQueue",
|
||||
src: "httpMessageQueue",
|
||||
},
|
||||
on: {
|
||||
ChatLogout: {
|
||||
target: "#chat.idle",
|
||||
@@ -496,6 +533,12 @@ export const chatMachine = setup({
|
||||
target: "#chat.vipUserSession",
|
||||
actions: "startUserSession",
|
||||
},
|
||||
ChatQueuedHttpDone: {
|
||||
actions: "applyQueuedHttpOutput",
|
||||
},
|
||||
ChatQueuedSendError: {
|
||||
actions: "appendSocketErrorMessage",
|
||||
},
|
||||
},
|
||||
initial: "initializing",
|
||||
states: {
|
||||
@@ -525,8 +568,7 @@ export const chatMachine = setup({
|
||||
on: {
|
||||
ChatSendMessage: {
|
||||
guard: ({ event }) => event.content.trim().length > 0,
|
||||
actions: "appendUserMessage",
|
||||
target: "sendingViaHttp",
|
||||
actions: ["appendUserMessage", "enqueueMessage"],
|
||||
},
|
||||
ChatSendImage: {
|
||||
actions: "appendUserImage",
|
||||
@@ -624,16 +666,28 @@ export const chatMachine = setup({
|
||||
ChatPaywallStatusReceived: { actions: "applyPaywallStatus" },
|
||||
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
|
||||
ChatWebSocketConnected: { actions: "setWsConnected" },
|
||||
ChatQueuedHttpDone: {
|
||||
actions: "applyQueuedHttpOutput",
|
||||
},
|
||||
ChatQueuedSendError: {
|
||||
actions: "appendSocketErrorMessage",
|
||||
},
|
||||
},
|
||||
initial: "initializing",
|
||||
exit: "clearWsConnected",
|
||||
invoke: {
|
||||
// 父级:WS 长连接(一进来就连,跨 initializing/ready/sending*/loadingMore)
|
||||
src: "chatWebSocket",
|
||||
input: ({ event }) => ({
|
||||
token: event.type === "ChatVipLogin" ? event.token : "",
|
||||
}),
|
||||
},
|
||||
invoke: [
|
||||
{
|
||||
// 父级:WS 长连接(一进来就连,跨 initializing/ready/loadingMore)
|
||||
src: "chatWebSocket",
|
||||
input: ({ event }) => ({
|
||||
token: event.type === "ChatVipLogin" ? event.token : "",
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "messageQueue",
|
||||
src: "wsPreferredMessageQueue",
|
||||
},
|
||||
],
|
||||
states: {
|
||||
initializing: {
|
||||
// barrier:WS 连上 + history 加载完成才进 ready
|
||||
@@ -666,25 +720,10 @@ export const chatMachine = setup({
|
||||
},
|
||||
ready: {
|
||||
on: {
|
||||
// 发送消息:wsConnected → 走 WS(流式回 reply),否则 → 走 HTTP(一次性回 reply)
|
||||
// 两个 branch 都有 "content 非空" guard(仅作 fallback 兜底:wsConnected=true 也需
|
||||
// 内容非空;wsConnected=false 同理)
|
||||
ChatSendMessage: [
|
||||
{
|
||||
// VIP 用户优先走 WS;WS 尚未可用时由下一个 branch 走 HTTP fallback。
|
||||
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",
|
||||
},
|
||||
],
|
||||
ChatSendMessage: {
|
||||
guard: ({ event }) => event.content.trim().length > 0,
|
||||
actions: ["appendUserMessage", "enqueueMessage"],
|
||||
},
|
||||
ChatSendImage: {
|
||||
actions: "appendUserImage",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user