fix(chat): track replies by queued batch

This commit is contained in:
2026-06-26 11:42:17 +08:00
parent ceaa3ebfcf
commit 58236453ed
6 changed files with 126 additions and 34 deletions
@@ -87,6 +87,7 @@ function createTestChatMachine(
httpMessageQueue: fromCallback<ChatEvent>(({ receive, sendBack }) => {
receive((event) => {
if (event.type !== "ChatSendMessage") return;
sendBack({ type: "ChatQueuedSendStarted" });
sendBack({
type: "ChatQueuedHttpDone",
output: {
@@ -266,4 +267,96 @@ describe("chatMachine transitions", () => {
actor.stop();
});
it("tracks replying state by queued batch instead of user message count", async () => {
const machine = chatMachine.provide({
actors: {
loadHistory: fromPromise<LoadHistoryOutput>(async () => ({
messages: [],
hasMore: false,
newOffset: 0,
localOverwritten: true,
localCount: 0,
networkCount: 0,
})),
loadMoreHistory: fromPromise<LoadMoreHistoryOutput, { offset: number }>(
async () => ({
messages: [],
hasMore: false,
newOffset: 0,
}),
),
sendMessageHttp: fromPromise<
SendMessageHttpOutput,
{ content: string }
>(async () => ({
response: makeChatSendResponse(),
reply: null,
})),
sendMessageWs: fromPromise<void, { content: string }>(async () => {}),
httpMessageQueue: fromCallback<ChatEvent>(({ receive, sendBack }) => {
const pending: string[] = [];
receive((event) => {
if (event.type !== "ChatSendMessage") return;
pending.push(event.content);
if (pending.length !== 2) return;
sendBack({ type: "ChatQueuedSendStarted" });
sendBack({
type: "ChatQueuedHttpDone",
output: {
response: makeChatSendResponse(),
reply: null,
},
});
});
return () => undefined;
}),
wsPreferredMessageQueue: fromCallback<ChatEvent>(({ receive }) => {
receive(() => undefined);
return () => undefined;
}),
unlockPrivateMessage: fromPromise<
UnlockPrivateOutput,
{ messageId: string }
>(async ({ input }) => ({
messageId: input.messageId,
response: UnlockPrivateResponse.from({
unlocked: true,
content: "unlocked",
showUpgrade: false,
paywallTriggered: false,
privateFreeLimit: 0,
privateUsedToday: 0,
reason: "ok",
}),
})),
chatWebSocket: fromCallback(({ sendBack }) => {
sendBack({
type: "ChatWebSocketConnected",
userId: "user-1",
});
return () => undefined;
}),
},
});
const actor = createActor(machine).start();
actor.send({ type: "ChatNonVipLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ nonVipUserSession: "ready" }),
);
actor.send({ type: "ChatSendMessage", content: "first" });
actor.send({ type: "ChatSendMessage", content: "second" });
await waitFor(
actor,
(snapshot) => snapshot.context.messages.length === 2,
);
expect(actor.getSnapshot().context.pendingReplyCount).toBe(0);
expect(actor.getSnapshot().context.isReplyingAI).toBe(false);
actor.stop();
});
});
+1
View File
@@ -27,6 +27,7 @@ export type ChatEvent =
| { type: "ChatSendImage"; imageBase64: string }
| { type: "ChatUnlockPrivateMessage"; messageId: string }
| { type: "ChatLoadMoreHistory" }
| { type: "ChatQueuedSendStarted" }
| {
type: "ChatQueuedHttpDone";
output: import("./chat-machine.helpers").HttpSendOutput;
+2 -3
View File
@@ -242,10 +242,11 @@ function createMessageQueueActor(
const queue = new MessageQueue();
queue.setConsumer(async (content) => {
sendBack({ type: "ChatQueuedSendStarted" });
try {
if (mode === "wsPreferred") {
const ws = getActiveChatWebSocket();
if (ws?.sendMessage(content)) return true;
if (ws?.sendMessage(content)) return;
log.debug("[chat-machine] message queue fallback to HTTP", {
contentLength: content.length,
});
@@ -253,7 +254,6 @@ function createMessageQueueActor(
const output = await sendMessageViaHttp(content);
sendBack({ type: "ChatQueuedHttpDone", output });
return true;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Message send failed";
@@ -266,7 +266,6 @@ function createMessageQueueActor(
content,
errorMessage,
});
return true;
}
});
+13 -4
View File
@@ -111,7 +111,7 @@ export const chatMachine = setup({
contentLength: event.content.length,
contentPreview: event.content.slice(0, 50),
quotaMode: "server controlled",
isReplyingAI: true,
isReplyingAI: context.isReplyingAI,
});
return {
@@ -123,7 +123,6 @@ export const chatMachine = setup({
date: today,
},
],
...beginPendingReply(context),
upgradePromptVisible: false,
upgradeReason: null,
upgradeHint: null,
@@ -139,7 +138,7 @@ export const chatMachine = setup({
contentLength: event.content.length,
contentPreview: event.content.slice(0, 50),
wsConnected: context.wsConnected,
isReplyingAI: true,
isReplyingAI: context.isReplyingAI,
});
return {
@@ -151,7 +150,6 @@ export const chatMachine = setup({
date: today,
},
],
...beginPendingReply(context),
upgradePromptVisible: false,
upgradeReason: null,
upgradeHint: null,
@@ -261,6 +259,8 @@ export const chatMachine = setup({
return { messages, ...finishPendingReply(context) };
}),
markQueuedSendStarted: assign(({ context }) => beginPendingReply(context)),
applyQueuedHttpOutput: assign(({ context, event }) => {
if (event.type !== "ChatQueuedHttpDone") return {};
return applyHttpSendOutput(context, event.output);
@@ -423,6 +423,9 @@ export const chatMachine = setup({
target: "#chat.vipUserSession",
actions: "startUserSession",
},
ChatQueuedSendStarted: {
actions: "markQueuedSendStarted",
},
ChatQueuedHttpDone: {
actions: "applyQueuedHttpOutput",
},
@@ -533,6 +536,9 @@ export const chatMachine = setup({
target: "#chat.vipUserSession",
actions: "startUserSession",
},
ChatQueuedSendStarted: {
actions: "markQueuedSendStarted",
},
ChatQueuedHttpDone: {
actions: "applyQueuedHttpOutput",
},
@@ -666,6 +672,9 @@ export const chatMachine = setup({
ChatPaywallStatusReceived: { actions: "applyPaywallStatus" },
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
ChatWebSocketConnected: { actions: "setWsConnected" },
ChatQueuedSendStarted: {
actions: "markQueuedSendStarted",
},
ChatQueuedHttpDone: {
actions: "applyQueuedHttpOutput",
},