fix(chat): queue outgoing messages
This commit is contained in:
@@ -202,7 +202,7 @@ export function ChatScreen() {
|
|||||||
onUnlock={handleMessageLimitUnlock}
|
onUnlock={handleMessageLimitUnlock}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ChatInputBar disabled={state.isReplyingAI} />
|
<ChatInputBar disabled={!state.historyLoaded} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
|||||||
return {
|
return {
|
||||||
messages: [],
|
messages: [],
|
||||||
isReplyingAI: true,
|
isReplyingAI: true,
|
||||||
|
pendingReplyCount: 1,
|
||||||
wsConnected: false,
|
wsConnected: false,
|
||||||
upgradePromptVisible: false,
|
upgradePromptVisible: false,
|
||||||
upgradeReason: null,
|
upgradeReason: null,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type UiMessage,
|
type UiMessage,
|
||||||
} from "@/data/dto/chat";
|
} from "@/data/dto/chat";
|
||||||
import { chatMachine } from "@/stores/chat/chat-machine";
|
import { chatMachine } from "@/stores/chat/chat-machine";
|
||||||
|
import type { ChatEvent } from "@/stores/chat/chat-events";
|
||||||
|
|
||||||
interface LoadHistoryOutput {
|
interface LoadHistoryOutput {
|
||||||
messages: UiMessage[];
|
messages: UiMessage[];
|
||||||
@@ -83,6 +84,23 @@ function createTestChatMachine(
|
|||||||
reply: null,
|
reply: null,
|
||||||
})),
|
})),
|
||||||
sendMessageWs: fromPromise<void, { content: string }>(async () => {}),
|
sendMessageWs: fromPromise<void, { content: string }>(async () => {}),
|
||||||
|
httpMessageQueue: fromCallback<ChatEvent>(({ receive, sendBack }) => {
|
||||||
|
receive((event) => {
|
||||||
|
if (event.type !== "ChatSendMessage") return;
|
||||||
|
sendBack({
|
||||||
|
type: "ChatQueuedHttpDone",
|
||||||
|
output: {
|
||||||
|
response: makeChatSendResponse(),
|
||||||
|
reply: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => undefined;
|
||||||
|
}),
|
||||||
|
wsPreferredMessageQueue: fromCallback<ChatEvent>(({ receive }) => {
|
||||||
|
receive(() => undefined);
|
||||||
|
return () => undefined;
|
||||||
|
}),
|
||||||
unlockPrivateMessage: fromPromise<
|
unlockPrivateMessage: fromPromise<
|
||||||
UnlockPrivateOutput,
|
UnlockPrivateOutput,
|
||||||
{ messageId: string }
|
{ messageId: string }
|
||||||
@@ -220,4 +238,32 @@ describe("chatMachine transitions", () => {
|
|||||||
|
|
||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows multiple messages to be queued without leaving ready state", async () => {
|
||||||
|
const actor = createActor(createTestChatMachine()).start();
|
||||||
|
|
||||||
|
actor.send({ type: "ChatNonVipLogin", token: "token" });
|
||||||
|
await waitFor(actor, (snapshot) =>
|
||||||
|
snapshot.matches({ nonVipUserSession: "ready" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
actor.send({ type: "ChatSendMessage", content: "hello" });
|
||||||
|
actor.send({ type: "ChatSendMessage", content: "still there?" });
|
||||||
|
|
||||||
|
expect(actor.getSnapshot().matches({ nonVipUserSession: "ready" })).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||||
|
{ content: "hello", isFromAI: false },
|
||||||
|
{ content: "still there?", isFromAI: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
actor,
|
||||||
|
(snapshot) => snapshot.context.pendingReplyCount === 0,
|
||||||
|
);
|
||||||
|
expect(actor.getSnapshot().context.isReplyingAI).toBe(false);
|
||||||
|
|
||||||
|
actor.stop();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ export type ChatEvent =
|
|||||||
| { type: "ChatSendImage"; imageBase64: string }
|
| { type: "ChatSendImage"; imageBase64: string }
|
||||||
| { type: "ChatUnlockPrivateMessage"; messageId: string }
|
| { type: "ChatUnlockPrivateMessage"; messageId: string }
|
||||||
| { type: "ChatLoadMoreHistory" }
|
| { type: "ChatLoadMoreHistory" }
|
||||||
|
| {
|
||||||
|
type: "ChatQueuedHttpDone";
|
||||||
|
output: import("./chat-machine.helpers").HttpSendOutput;
|
||||||
|
}
|
||||||
|
| { type: "ChatQueuedSendError"; content: string; errorMessage: string }
|
||||||
| { type: "ChatImageReceived"; url: string }
|
| { type: "ChatImageReceived"; url: string }
|
||||||
| {
|
| {
|
||||||
type: "ChatPaywallStatusReceived";
|
type: "ChatPaywallStatusReceived";
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Chat 状态机:4 个 XState actor
|
* Chat 状态机:history / send / websocket / queue actors
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fromPromise, fromCallback } from "xstate";
|
import { fromPromise, fromCallback } from "xstate";
|
||||||
|
|
||||||
import { createChatWebSocket, ChatWebSocket } from "@/core/net/chat-websocket";
|
import { createChatWebSocket, ChatWebSocket } from "@/core/net/chat-websocket";
|
||||||
|
import { MessageQueue } from "@/core/net/message-queue";
|
||||||
import type { ChatSendResponse } from "@/data/dto/chat";
|
import type { ChatSendResponse } from "@/data/dto/chat";
|
||||||
import { Result, Logger } from "@/utils";
|
import { Result, Logger } from "@/utils";
|
||||||
|
|
||||||
@@ -74,19 +75,7 @@ export const sendMessageHttpActor = fromPromise<
|
|||||||
{ response: ChatSendResponse; reply: UiMessage | null },
|
{ response: ChatSendResponse; reply: UiMessage | null },
|
||||||
{ content: string }
|
{ content: string }
|
||||||
>(async ({ input }) => {
|
>(async ({ input }) => {
|
||||||
const result = await chatRepo.sendMessage(input.content);
|
return sendMessageViaHttp(input.content);
|
||||||
if (Result.isErr(result)) {
|
|
||||||
log.error("[chat-machine] sendMessageHttpActor failed", { error: result.error });
|
|
||||||
throw result.error;
|
|
||||||
}
|
|
||||||
const isDailyLimit =
|
|
||||||
result.data.lockDetail.locked &&
|
|
||||||
result.data.lockDetail.showUpgrade &&
|
|
||||||
result.data.lockDetail.reason === "daily_limit";
|
|
||||||
return {
|
|
||||||
response: result.data,
|
|
||||||
reply: isDailyLimit ? null : sendResponseToUiMessage(result.data),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 翻历史(pagination)—— 不走 local-first,纯 server fetch */
|
/** 翻历史(pagination)—— 不走 local-first,纯 server fetch */
|
||||||
@@ -233,5 +222,82 @@ export const sendMessageWsActor = fromPromise<void, { content: string }>(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const httpMessageQueueActor = fromCallback<ChatEvent>(
|
||||||
|
({ sendBack, receive }) => {
|
||||||
|
return createMessageQueueActor("http", sendBack, receive);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const wsPreferredMessageQueueActor = fromCallback<ChatEvent>(
|
||||||
|
({ sendBack, receive }) => {
|
||||||
|
return createMessageQueueActor("wsPreferred", sendBack, receive);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function createMessageQueueActor(
|
||||||
|
mode: "http" | "wsPreferred",
|
||||||
|
sendBack: (event: ChatEvent) => void,
|
||||||
|
receive: (listener: (event: ChatEvent) => void) => void,
|
||||||
|
): () => void {
|
||||||
|
const queue = new MessageQueue();
|
||||||
|
|
||||||
|
queue.setConsumer(async (content) => {
|
||||||
|
try {
|
||||||
|
if (mode === "wsPreferred") {
|
||||||
|
const ws = getActiveChatWebSocket();
|
||||||
|
if (ws?.sendMessage(content)) return true;
|
||||||
|
log.debug("[chat-machine] message queue fallback to HTTP", {
|
||||||
|
contentLength: content.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = await sendMessageViaHttp(content);
|
||||||
|
sendBack({ type: "ChatQueuedHttpDone", output });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "Message send failed";
|
||||||
|
log.error("[chat-machine] message queue send failed", {
|
||||||
|
error,
|
||||||
|
mode,
|
||||||
|
});
|
||||||
|
sendBack({
|
||||||
|
type: "ChatQueuedSendError",
|
||||||
|
content,
|
||||||
|
errorMessage,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
receive((event) => {
|
||||||
|
if (event.type !== "ChatSendMessage") return;
|
||||||
|
const content = event.content.trim();
|
||||||
|
if (content.length === 0) return;
|
||||||
|
queue.enqueue(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => queue.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
// Re-export `UiMessage` type for the chat-machine.ts public API
|
// Re-export `UiMessage` type for the chat-machine.ts public API
|
||||||
type UiMessage = import("@/data/dto/chat").UiMessage;
|
type UiMessage = import("@/data/dto/chat").UiMessage;
|
||||||
|
|
||||||
|
async function sendMessageViaHttp(content: string): Promise<{
|
||||||
|
response: ChatSendResponse;
|
||||||
|
reply: UiMessage | null;
|
||||||
|
}> {
|
||||||
|
const result = await chatRepo.sendMessage(content);
|
||||||
|
if (Result.isErr(result)) {
|
||||||
|
log.error("[chat-machine] sendMessageHttpActor failed", { error: result.error });
|
||||||
|
throw result.error;
|
||||||
|
}
|
||||||
|
const isDailyLimit =
|
||||||
|
result.data.lockDetail.locked &&
|
||||||
|
result.data.lockDetail.showUpgrade &&
|
||||||
|
result.data.lockDetail.reason === "daily_limit";
|
||||||
|
return {
|
||||||
|
response: result.data,
|
||||||
|
reply: isDailyLimit ? null : sendResponseToUiMessage(result.data),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -136,6 +136,27 @@ export type HttpSendOutput = {
|
|||||||
reply: UiMessage | null;
|
reply: UiMessage | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function beginPendingReply(context: ChatState): Pick<
|
||||||
|
ChatState,
|
||||||
|
"isReplyingAI" | "pendingReplyCount"
|
||||||
|
> {
|
||||||
|
return {
|
||||||
|
pendingReplyCount: context.pendingReplyCount + 1,
|
||||||
|
isReplyingAI: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finishPendingReply(context: ChatState): Pick<
|
||||||
|
ChatState,
|
||||||
|
"isReplyingAI" | "pendingReplyCount"
|
||||||
|
> {
|
||||||
|
const pendingReplyCount = Math.max(0, context.pendingReplyCount - 1);
|
||||||
|
return {
|
||||||
|
pendingReplyCount,
|
||||||
|
isReplyingAI: pendingReplyCount > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function applyHttpSendOutput(
|
export function applyHttpSendOutput(
|
||||||
context: ChatState,
|
context: ChatState,
|
||||||
output: HttpSendOutput,
|
output: HttpSendOutput,
|
||||||
@@ -157,7 +178,7 @@ export function applyHttpSendOutput(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
isReplyingAI: false,
|
...finishPendingReply(context),
|
||||||
upgradePromptVisible: true,
|
upgradePromptVisible: true,
|
||||||
upgradeReason: "daily_limit",
|
upgradeReason: "daily_limit",
|
||||||
upgradeHint: null,
|
upgradeHint: null,
|
||||||
@@ -167,14 +188,14 @@ export function applyHttpSendOutput(
|
|||||||
|
|
||||||
if (!reply) {
|
if (!reply) {
|
||||||
return {
|
return {
|
||||||
isReplyingAI: false,
|
...finishPendingReply(context),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lockDetail.showUpgrade && lockDetail.reason === "private_message") {
|
if (lockDetail.showUpgrade && lockDetail.reason === "private_message") {
|
||||||
return {
|
return {
|
||||||
messages: [...context.messages, reply],
|
messages: [...context.messages, reply],
|
||||||
isReplyingAI: false,
|
...finishPendingReply(context),
|
||||||
upgradePromptVisible: false,
|
upgradePromptVisible: false,
|
||||||
upgradeReason: null,
|
upgradeReason: null,
|
||||||
upgradeHint: null,
|
upgradeHint: null,
|
||||||
@@ -184,7 +205,7 @@ export function applyHttpSendOutput(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
messages: [...context.messages, reply],
|
messages: [...context.messages, reply],
|
||||||
isReplyingAI: false,
|
...finishPendingReply(context),
|
||||||
upgradePromptVisible: false,
|
upgradePromptVisible: false,
|
||||||
upgradeReason: null,
|
upgradeReason: null,
|
||||||
upgradeHint: null,
|
upgradeHint: null,
|
||||||
|
|||||||
@@ -27,9 +27,10 @@
|
|||||||
* - 每个任务都是独立 actor,不互相等待(除非 `always` barrier)
|
* - 每个任务都是独立 actor,不互相等待(除非 `always` barrier)
|
||||||
*
|
*
|
||||||
* 消息发送路径(业务事实):
|
* 消息发送路径(业务事实):
|
||||||
* - guestSession:走 HTTP,消息数量限制以后端 `lockDetail` 响应为准
|
* - `ChatSendMessage` 只负责即时追加用户消息 + 入队,不进入 sending 子状态
|
||||||
* - nonVipUserSession:走 HTTP,让后端返回 daily_limit lockDetail
|
* - guestSession / nonVipUserSession:队列串行走 HTTP,限制以后端 `lockDetail` 为准
|
||||||
* - vipUserSession + WS 已连:走 WS,不受每日免费次数限制
|
* - vipUserSession:队列优先走 WS,WS 不可用时 fallback HTTP
|
||||||
|
* - `pendingReplyCount` 跟踪待回复数量,`isReplyingAI` 由计数派生
|
||||||
* - `ChatWebSocketConnected` 事件 → `wsConnected = true`
|
* - `ChatWebSocketConnected` 事件 → `wsConnected = true`
|
||||||
* - `vipUserSession.exit` action → `wsConnected = false`(WS actor cleanup 时也会跑 exit)
|
* - `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";
|
import { todayString, Logger } from "@/utils";
|
||||||
|
|
||||||
@@ -47,6 +48,8 @@ import { ChatState, initialState } from "./chat-state";
|
|||||||
import type { ChatEvent } from "./chat-events";
|
import type { ChatEvent } from "./chat-events";
|
||||||
import {
|
import {
|
||||||
applyHttpSendOutput,
|
applyHttpSendOutput,
|
||||||
|
beginPendingReply,
|
||||||
|
finishPendingReply,
|
||||||
normalizeLockDetail,
|
normalizeLockDetail,
|
||||||
} from "./chat-machine.helpers";
|
} from "./chat-machine.helpers";
|
||||||
import {
|
import {
|
||||||
@@ -55,7 +58,9 @@ import {
|
|||||||
sendMessageWsActor,
|
sendMessageWsActor,
|
||||||
loadMoreHistoryActor,
|
loadMoreHistoryActor,
|
||||||
chatWebSocketActor,
|
chatWebSocketActor,
|
||||||
|
httpMessageQueueActor,
|
||||||
unlockPrivateMessageActor,
|
unlockPrivateMessageActor,
|
||||||
|
wsPreferredMessageQueueActor,
|
||||||
} from "./chat-machine.actors";
|
} from "./chat-machine.actors";
|
||||||
|
|
||||||
const log = new Logger("StoresChatChatMachine");
|
const log = new Logger("StoresChatChatMachine");
|
||||||
@@ -79,9 +84,13 @@ export const chatMachine = setup({
|
|||||||
sendMessageWs: sendMessageWsActor,
|
sendMessageWs: sendMessageWsActor,
|
||||||
loadMoreHistory: loadMoreHistoryActor,
|
loadMoreHistory: loadMoreHistoryActor,
|
||||||
chatWebSocket: chatWebSocketActor,
|
chatWebSocket: chatWebSocketActor,
|
||||||
|
httpMessageQueue: httpMessageQueueActor,
|
||||||
|
wsPreferredMessageQueue: wsPreferredMessageQueueActor,
|
||||||
unlockPrivateMessage: unlockPrivateMessageActor,
|
unlockPrivateMessage: unlockPrivateMessageActor,
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
enqueueMessage: sendTo("messageQueue", ({ event }) => event),
|
||||||
|
|
||||||
startGuestSession: assign(() => ({
|
startGuestSession: assign(() => ({
|
||||||
...initialState,
|
...initialState,
|
||||||
})),
|
})),
|
||||||
@@ -114,7 +123,7 @@ export const chatMachine = setup({
|
|||||||
date: today,
|
date: today,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isReplyingAI: true,
|
...beginPendingReply(context),
|
||||||
upgradePromptVisible: false,
|
upgradePromptVisible: false,
|
||||||
upgradeReason: null,
|
upgradeReason: null,
|
||||||
upgradeHint: null,
|
upgradeHint: null,
|
||||||
@@ -142,7 +151,7 @@ export const chatMachine = setup({
|
|||||||
date: today,
|
date: today,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isReplyingAI: true,
|
...beginPendingReply(context),
|
||||||
upgradePromptVisible: false,
|
upgradePromptVisible: false,
|
||||||
upgradeReason: null,
|
upgradeReason: null,
|
||||||
upgradeHint: null,
|
upgradeHint: null,
|
||||||
@@ -168,7 +177,7 @@ export const chatMachine = setup({
|
|||||||
imageUrl: event.imageBase64,
|
imageUrl: event.imageBase64,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isReplyingAI: true,
|
...beginPendingReply(context),
|
||||||
upgradePromptVisible: false,
|
upgradePromptVisible: false,
|
||||||
upgradeReason: null,
|
upgradeReason: null,
|
||||||
upgradeHint: null,
|
upgradeHint: null,
|
||||||
@@ -195,7 +204,7 @@ export const chatMachine = setup({
|
|||||||
imageUrl: event.imageBase64,
|
imageUrl: event.imageBase64,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isReplyingAI: true,
|
...beginPendingReply(context),
|
||||||
upgradePromptVisible: false,
|
upgradePromptVisible: false,
|
||||||
upgradeReason: null,
|
upgradeReason: null,
|
||||||
upgradeHint: null,
|
upgradeHint: null,
|
||||||
@@ -227,7 +236,17 @@ export const chatMachine = setup({
|
|||||||
done: event.done,
|
done: event.done,
|
||||||
messagesCount: messages.length,
|
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 }) => {
|
appendSocketErrorMessage: assign(({ context }) => {
|
||||||
@@ -239,7 +258,12 @@ export const chatMachine = setup({
|
|||||||
date: todayString(),
|
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 }) => {
|
appendAIImage: assign(({ context, event }) => {
|
||||||
@@ -310,7 +334,7 @@ export const chatMachine = setup({
|
|||||||
|
|
||||||
applyUnlockPrivateOutput: assign(({ context, event }) => {
|
applyUnlockPrivateOutput: assign(({ context, event }) => {
|
||||||
if (!("output" in event)) return {};
|
if (!("output" in event)) return {};
|
||||||
const output = event.output as {
|
const output = event.output as unknown as {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
response: import("@/data/dto/chat").UnlockPrivateResponse;
|
response: import("@/data/dto/chat").UnlockPrivateResponse;
|
||||||
};
|
};
|
||||||
@@ -386,6 +410,10 @@ export const chatMachine = setup({
|
|||||||
},
|
},
|
||||||
|
|
||||||
guestSession: {
|
guestSession: {
|
||||||
|
invoke: {
|
||||||
|
id: "messageQueue",
|
||||||
|
src: "httpMessageQueue",
|
||||||
|
},
|
||||||
on: {
|
on: {
|
||||||
ChatNonVipLogin: {
|
ChatNonVipLogin: {
|
||||||
target: "#chat.nonVipUserSession",
|
target: "#chat.nonVipUserSession",
|
||||||
@@ -395,6 +423,12 @@ export const chatMachine = setup({
|
|||||||
target: "#chat.vipUserSession",
|
target: "#chat.vipUserSession",
|
||||||
actions: "startUserSession",
|
actions: "startUserSession",
|
||||||
},
|
},
|
||||||
|
ChatQueuedHttpDone: {
|
||||||
|
actions: "applyQueuedHttpOutput",
|
||||||
|
},
|
||||||
|
ChatQueuedSendError: {
|
||||||
|
actions: "appendSocketErrorMessage",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
initial: "initializing",
|
initial: "initializing",
|
||||||
states: {
|
states: {
|
||||||
@@ -428,9 +462,8 @@ export const chatMachine = setup({
|
|||||||
ready: {
|
ready: {
|
||||||
on: {
|
on: {
|
||||||
ChatSendMessage: {
|
ChatSendMessage: {
|
||||||
actions: "appendGuestUserMessage",
|
actions: ["appendGuestUserMessage", "enqueueMessage"],
|
||||||
guard: ({ event }) => event.content.trim().length > 0,
|
guard: ({ event }) => event.content.trim().length > 0,
|
||||||
target: "sending",
|
|
||||||
},
|
},
|
||||||
ChatSendImage: {
|
ChatSendImage: {
|
||||||
actions: "appendGuestUserImage",
|
actions: "appendGuestUserImage",
|
||||||
@@ -482,6 +515,10 @@ export const chatMachine = setup({
|
|||||||
},
|
},
|
||||||
|
|
||||||
nonVipUserSession: {
|
nonVipUserSession: {
|
||||||
|
invoke: {
|
||||||
|
id: "messageQueue",
|
||||||
|
src: "httpMessageQueue",
|
||||||
|
},
|
||||||
on: {
|
on: {
|
||||||
ChatLogout: {
|
ChatLogout: {
|
||||||
target: "#chat.idle",
|
target: "#chat.idle",
|
||||||
@@ -496,6 +533,12 @@ export const chatMachine = setup({
|
|||||||
target: "#chat.vipUserSession",
|
target: "#chat.vipUserSession",
|
||||||
actions: "startUserSession",
|
actions: "startUserSession",
|
||||||
},
|
},
|
||||||
|
ChatQueuedHttpDone: {
|
||||||
|
actions: "applyQueuedHttpOutput",
|
||||||
|
},
|
||||||
|
ChatQueuedSendError: {
|
||||||
|
actions: "appendSocketErrorMessage",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
initial: "initializing",
|
initial: "initializing",
|
||||||
states: {
|
states: {
|
||||||
@@ -525,8 +568,7 @@ export const chatMachine = setup({
|
|||||||
on: {
|
on: {
|
||||||
ChatSendMessage: {
|
ChatSendMessage: {
|
||||||
guard: ({ event }) => event.content.trim().length > 0,
|
guard: ({ event }) => event.content.trim().length > 0,
|
||||||
actions: "appendUserMessage",
|
actions: ["appendUserMessage", "enqueueMessage"],
|
||||||
target: "sendingViaHttp",
|
|
||||||
},
|
},
|
||||||
ChatSendImage: {
|
ChatSendImage: {
|
||||||
actions: "appendUserImage",
|
actions: "appendUserImage",
|
||||||
@@ -624,16 +666,28 @@ export const chatMachine = setup({
|
|||||||
ChatPaywallStatusReceived: { actions: "applyPaywallStatus" },
|
ChatPaywallStatusReceived: { actions: "applyPaywallStatus" },
|
||||||
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
|
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
|
||||||
ChatWebSocketConnected: { actions: "setWsConnected" },
|
ChatWebSocketConnected: { actions: "setWsConnected" },
|
||||||
|
ChatQueuedHttpDone: {
|
||||||
|
actions: "applyQueuedHttpOutput",
|
||||||
|
},
|
||||||
|
ChatQueuedSendError: {
|
||||||
|
actions: "appendSocketErrorMessage",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
initial: "initializing",
|
initial: "initializing",
|
||||||
exit: "clearWsConnected",
|
exit: "clearWsConnected",
|
||||||
invoke: {
|
invoke: [
|
||||||
// 父级:WS 长连接(一进来就连,跨 initializing/ready/sending*/loadingMore)
|
{
|
||||||
src: "chatWebSocket",
|
// 父级:WS 长连接(一进来就连,跨 initializing/ready/loadingMore)
|
||||||
input: ({ event }) => ({
|
src: "chatWebSocket",
|
||||||
token: event.type === "ChatVipLogin" ? event.token : "",
|
input: ({ event }) => ({
|
||||||
}),
|
token: event.type === "ChatVipLogin" ? event.token : "",
|
||||||
},
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "messageQueue",
|
||||||
|
src: "wsPreferredMessageQueue",
|
||||||
|
},
|
||||||
|
],
|
||||||
states: {
|
states: {
|
||||||
initializing: {
|
initializing: {
|
||||||
// barrier:WS 连上 + history 加载完成才进 ready
|
// barrier:WS 连上 + history 加载完成才进 ready
|
||||||
@@ -666,25 +720,10 @@ export const chatMachine = setup({
|
|||||||
},
|
},
|
||||||
ready: {
|
ready: {
|
||||||
on: {
|
on: {
|
||||||
// 发送消息:wsConnected → 走 WS(流式回 reply),否则 → 走 HTTP(一次性回 reply)
|
ChatSendMessage: {
|
||||||
// 两个 branch 都有 "content 非空" guard(仅作 fallback 兜底:wsConnected=true 也需
|
guard: ({ event }) => event.content.trim().length > 0,
|
||||||
// 内容非空;wsConnected=false 同理)
|
actions: ["appendUserMessage", "enqueueMessage"],
|
||||||
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",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
ChatSendImage: {
|
ChatSendImage: {
|
||||||
actions: "appendUserImage",
|
actions: "appendUserImage",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { UiMessage } from "@/data/dto/chat";
|
|||||||
export interface ChatState {
|
export interface ChatState {
|
||||||
messages: UiMessage[];
|
messages: UiMessage[];
|
||||||
isReplyingAI: boolean;
|
isReplyingAI: boolean;
|
||||||
|
pendingReplyCount: number;
|
||||||
/**
|
/**
|
||||||
* WebSocket 连接状态(仅 VIP 用户会话期间 true)
|
* WebSocket 连接状态(仅 VIP 用户会话期间 true)
|
||||||
* - VIP true:走 WS
|
* - VIP true:走 WS
|
||||||
@@ -34,6 +35,7 @@ export interface ChatState {
|
|||||||
export const initialState: ChatState = {
|
export const initialState: ChatState = {
|
||||||
messages: [],
|
messages: [],
|
||||||
isReplyingAI: false,
|
isReplyingAI: false,
|
||||||
|
pendingReplyCount: 0,
|
||||||
wsConnected: false,
|
wsConnected: false,
|
||||||
upgradePromptVisible: false,
|
upgradePromptVisible: false,
|
||||||
upgradeReason: null,
|
upgradeReason: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user