refactor(chat): remove vip websocket session flow
This commit is contained in:
@@ -35,7 +35,6 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
|||||||
messages: [],
|
messages: [],
|
||||||
isReplyingAI: true,
|
isReplyingAI: true,
|
||||||
pendingReplyCount: 1,
|
pendingReplyCount: 1,
|
||||||
wsConnected: false,
|
|
||||||
upgradePromptVisible: false,
|
upgradePromptVisible: false,
|
||||||
upgradeReason: null,
|
upgradeReason: null,
|
||||||
upgradeHint: null,
|
upgradeHint: null,
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ function createTestChatMachine(
|
|||||||
response: makeChatSendResponse(),
|
response: makeChatSendResponse(),
|
||||||
reply: null,
|
reply: null,
|
||||||
})),
|
})),
|
||||||
sendMessageWs: fromPromise<void, { content: string }>(async () => {}),
|
|
||||||
httpMessageQueue: fromCallback<ChatEvent>(({ receive, sendBack }) => {
|
httpMessageQueue: fromCallback<ChatEvent>(({ receive, sendBack }) => {
|
||||||
receive((event) => {
|
receive((event) => {
|
||||||
if (event.type !== "ChatSendMessage") return;
|
if (event.type !== "ChatSendMessage") return;
|
||||||
@@ -98,10 +97,6 @@ function createTestChatMachine(
|
|||||||
});
|
});
|
||||||
return () => undefined;
|
return () => undefined;
|
||||||
}),
|
}),
|
||||||
wsPreferredMessageQueue: fromCallback<ChatEvent>(({ receive }) => {
|
|
||||||
receive(() => undefined);
|
|
||||||
return () => undefined;
|
|
||||||
}),
|
|
||||||
unlockPrivateMessage: fromPromise<
|
unlockPrivateMessage: fromPromise<
|
||||||
UnlockPrivateOutput,
|
UnlockPrivateOutput,
|
||||||
{ messageId: string }
|
{ messageId: string }
|
||||||
@@ -117,19 +112,12 @@ function createTestChatMachine(
|
|||||||
reason: "ok",
|
reason: "ok",
|
||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
chatWebSocket: fromCallback(({ sendBack }) => {
|
|
||||||
sendBack({
|
|
||||||
type: "ChatWebSocketConnected",
|
|
||||||
userId: "user-1",
|
|
||||||
});
|
|
||||||
return () => undefined;
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("chatMachine transitions", () => {
|
describe("chatMachine transitions", () => {
|
||||||
it("keeps guest logout disabled while allowing upgrade to non-VIP login", async () => {
|
it("keeps guest logout disabled while allowing upgrade to user login", async () => {
|
||||||
const actor = createActor(createTestChatMachine()).start();
|
const actor = createActor(createTestChatMachine()).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatGuestLogin" });
|
actor.send({ type: "ChatGuestLogin" });
|
||||||
@@ -140,9 +128,9 @@ describe("chatMachine transitions", () => {
|
|||||||
actor.send({ type: "ChatLogout" });
|
actor.send({ type: "ChatLogout" });
|
||||||
expect(actor.getSnapshot().matches({ guestSession: "ready" })).toBe(true);
|
expect(actor.getSnapshot().matches({ guestSession: "ready" })).toBe(true);
|
||||||
|
|
||||||
actor.send({ type: "ChatNonVipLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
await waitFor(actor, (snapshot) =>
|
await waitFor(actor, (snapshot) =>
|
||||||
snapshot.matches({ nonVipUserSession: "ready" }),
|
snapshot.matches({ userSession: "ready" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
actor.send({ type: "ChatLogout" });
|
actor.send({ type: "ChatLogout" });
|
||||||
@@ -151,24 +139,16 @@ describe("chatMachine transitions", () => {
|
|||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enters VIP ready only after history and websocket are ready", async () => {
|
it("enters user ready after history is loaded", async () => {
|
||||||
const actor = createActor(createTestChatMachine()).start();
|
const actor = createActor(createTestChatMachine()).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatVipLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
await waitFor(actor, (snapshot) =>
|
await waitFor(actor, (snapshot) =>
|
||||||
snapshot.matches({ vipUserSession: "ready" }),
|
snapshot.matches({ userSession: "ready" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const snapshot = actor.getSnapshot();
|
const snapshot = actor.getSnapshot();
|
||||||
expect(snapshot.context.historyLoaded).toBe(true);
|
expect(snapshot.context.historyLoaded).toBe(true);
|
||||||
expect(snapshot.context.wsConnected).toBe(true);
|
|
||||||
|
|
||||||
actor.send({ type: "ChatNonVipLogin", token: "token" });
|
|
||||||
await waitFor(actor, (nextSnapshot) =>
|
|
||||||
nextSnapshot.matches({ nonVipUserSession: "ready" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(actor.getSnapshot().context.wsConnected).toBe(false);
|
|
||||||
|
|
||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
});
|
||||||
@@ -176,23 +156,13 @@ describe("chatMachine transitions", () => {
|
|||||||
it("ignores guest login while an authenticated user session is active", async () => {
|
it("ignores guest login while an authenticated user session is active", async () => {
|
||||||
const actor = createActor(createTestChatMachine()).start();
|
const actor = createActor(createTestChatMachine()).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatNonVipLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
await waitFor(actor, (snapshot) =>
|
await waitFor(actor, (snapshot) =>
|
||||||
snapshot.matches({ nonVipUserSession: "ready" }),
|
snapshot.matches({ userSession: "ready" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
actor.send({ type: "ChatGuestLogin" });
|
actor.send({ type: "ChatGuestLogin" });
|
||||||
expect(actor.getSnapshot().matches({ nonVipUserSession: "ready" })).toBe(
|
expect(actor.getSnapshot().matches({ userSession: "ready" })).toBe(true);
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
actor.send({ type: "ChatVipLogin", token: "token" });
|
|
||||||
await waitFor(actor, (snapshot) =>
|
|
||||||
snapshot.matches({ vipUserSession: "ready" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
actor.send({ type: "ChatGuestLogin" });
|
|
||||||
expect(actor.getSnapshot().matches({ vipUserSession: "ready" })).toBe(true);
|
|
||||||
|
|
||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
});
|
||||||
@@ -217,9 +187,9 @@ describe("chatMachine transitions", () => {
|
|||||||
}),
|
}),
|
||||||
).start();
|
).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatNonVipLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
await waitFor(actor, (snapshot) =>
|
await waitFor(actor, (snapshot) =>
|
||||||
snapshot.matches({ nonVipUserSession: "ready" }),
|
snapshot.matches({ userSession: "ready" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
actor.send({ type: "ChatUnlockPrivateMessage", messageId: "private-1" });
|
actor.send({ type: "ChatUnlockPrivateMessage", messageId: "private-1" });
|
||||||
@@ -243,17 +213,15 @@ describe("chatMachine transitions", () => {
|
|||||||
it("allows multiple messages to be queued without leaving ready state", async () => {
|
it("allows multiple messages to be queued without leaving ready state", async () => {
|
||||||
const actor = createActor(createTestChatMachine()).start();
|
const actor = createActor(createTestChatMachine()).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatNonVipLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
await waitFor(actor, (snapshot) =>
|
await waitFor(actor, (snapshot) =>
|
||||||
snapshot.matches({ nonVipUserSession: "ready" }),
|
snapshot.matches({ userSession: "ready" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
actor.send({ type: "ChatSendMessage", content: "hello" });
|
actor.send({ type: "ChatSendMessage", content: "hello" });
|
||||||
actor.send({ type: "ChatSendMessage", content: "still there?" });
|
actor.send({ type: "ChatSendMessage", content: "still there?" });
|
||||||
|
|
||||||
expect(actor.getSnapshot().matches({ nonVipUserSession: "ready" })).toBe(
|
expect(actor.getSnapshot().matches({ userSession: "ready" })).toBe(true);
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||||
{ content: "hello", isFromAI: false },
|
{ content: "hello", isFromAI: false },
|
||||||
{ content: "still there?", isFromAI: false },
|
{ content: "still there?", isFromAI: false },
|
||||||
@@ -293,7 +261,6 @@ describe("chatMachine transitions", () => {
|
|||||||
response: makeChatSendResponse(),
|
response: makeChatSendResponse(),
|
||||||
reply: null,
|
reply: null,
|
||||||
})),
|
})),
|
||||||
sendMessageWs: fromPromise<void, { content: string }>(async () => {}),
|
|
||||||
httpMessageQueue: fromCallback<ChatEvent>(({ receive, sendBack }) => {
|
httpMessageQueue: fromCallback<ChatEvent>(({ receive, sendBack }) => {
|
||||||
const pending: string[] = [];
|
const pending: string[] = [];
|
||||||
receive((event) => {
|
receive((event) => {
|
||||||
@@ -311,10 +278,6 @@ describe("chatMachine transitions", () => {
|
|||||||
});
|
});
|
||||||
return () => undefined;
|
return () => undefined;
|
||||||
}),
|
}),
|
||||||
wsPreferredMessageQueue: fromCallback<ChatEvent>(({ receive }) => {
|
|
||||||
receive(() => undefined);
|
|
||||||
return () => undefined;
|
|
||||||
}),
|
|
||||||
unlockPrivateMessage: fromPromise<
|
unlockPrivateMessage: fromPromise<
|
||||||
UnlockPrivateOutput,
|
UnlockPrivateOutput,
|
||||||
{ messageId: string }
|
{ messageId: string }
|
||||||
@@ -330,20 +293,13 @@ describe("chatMachine transitions", () => {
|
|||||||
reason: "ok",
|
reason: "ok",
|
||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
chatWebSocket: fromCallback(({ sendBack }) => {
|
|
||||||
sendBack({
|
|
||||||
type: "ChatWebSocketConnected",
|
|
||||||
userId: "user-1",
|
|
||||||
});
|
|
||||||
return () => undefined;
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const actor = createActor(machine).start();
|
const actor = createActor(machine).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatNonVipLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
await waitFor(actor, (snapshot) =>
|
await waitFor(actor, (snapshot) =>
|
||||||
snapshot.matches({ nonVipUserSession: "ready" }),
|
snapshot.matches({ userSession: "ready" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
actor.send({ type: "ChatSendMessage", content: "first" });
|
actor.send({ type: "ChatSendMessage", content: "first" });
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { usePathname, useRouter } from "next/navigation";
|
|||||||
|
|
||||||
import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
||||||
import { useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthState } from "@/stores/auth/auth-context";
|
||||||
import { useUserState } from "@/stores/user/user-context";
|
|
||||||
import { PROTECTED_ROUTES, ROUTES, type StaticRoute } from "@/router/routes";
|
import { PROTECTED_ROUTES, ROUTES, type StaticRoute } from "@/router/routes";
|
||||||
|
|
||||||
import { useChatDispatch } from "./chat-context";
|
import { useChatDispatch } from "./chat-context";
|
||||||
@@ -23,7 +22,6 @@ function isProtectedPath(pathname: string): boolean {
|
|||||||
|
|
||||||
export function ChatAuthSync() {
|
export function ChatAuthSync() {
|
||||||
const authState = useAuthState();
|
const authState = useAuthState();
|
||||||
const userState = useUserState();
|
|
||||||
const chatDispatch = useChatDispatch();
|
const chatDispatch = useChatDispatch();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -32,11 +30,7 @@ export function ChatAuthSync() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authState.hasInitialized || authState.isLoading) return;
|
if (!authState.hasInitialized || authState.isLoading) return;
|
||||||
|
|
||||||
const isVip = userState.currentUser?.isVip === true;
|
const sessionKey = authState.loginStatus;
|
||||||
const sessionKey =
|
|
||||||
authState.loginStatus === "notLoggedIn" || authState.loginStatus === "guest"
|
|
||||||
? authState.loginStatus
|
|
||||||
: `${authState.loginStatus}:${isVip ? "vip" : "non-vip"}`;
|
|
||||||
if (prevSessionKeyRef.current === sessionKey) return;
|
if (prevSessionKeyRef.current === sessionKey) return;
|
||||||
prevSessionKeyRef.current = sessionKey;
|
prevSessionKeyRef.current = sessionKey;
|
||||||
|
|
||||||
@@ -58,7 +52,7 @@ export function ChatAuthSync() {
|
|||||||
const tokenR = await AuthStorage.getInstance().getLoginToken();
|
const tokenR = await AuthStorage.getInstance().getLoginToken();
|
||||||
if (!cancelled && tokenR.success && tokenR.data) {
|
if (!cancelled && tokenR.success && tokenR.data) {
|
||||||
chatDispatch({
|
chatDispatch({
|
||||||
type: isVip ? "ChatVipLogin" : "ChatNonVipLogin",
|
type: "ChatUserLogin",
|
||||||
token: tokenR.data,
|
token: tokenR.data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -74,7 +68,6 @@ export function ChatAuthSync() {
|
|||||||
chatDispatch,
|
chatDispatch,
|
||||||
pathname,
|
pathname,
|
||||||
router,
|
router,
|
||||||
userState.currentUser?.isVip,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -8,19 +8,17 @@
|
|||||||
* chat 机器不感知鉴权。
|
* chat 机器不感知鉴权。
|
||||||
*
|
*
|
||||||
* 鉴权生命周期事件(本轮新增):
|
* 鉴权生命周期事件(本轮新增):
|
||||||
* - `ChatGuestLogin`:游客进入 /chat —— 拉本地消息 + 初始化配额(不连 WS)
|
* - `ChatGuestLogin`:游客进入 /chat —— 拉服务器端首屏,不连 WS
|
||||||
* - `ChatNonVipLogin`:非 VIP 用户进入 /chat —— 拉服务器端首屏,不连 WS
|
* - `ChatUserLogin`:其他登录用户进入 /chat —— 拉服务器端首屏,不连 WS
|
||||||
* - `ChatVipLogin`:VIP 用户进入 /chat —— 拉服务器端首屏 + 连 WS(token 在 payload)
|
* - `ChatLogout`:正式登录用户登出 —— 清消息
|
||||||
* - `ChatLogout`:正式登录用户登出 —— 断 WS + 清消息
|
|
||||||
*
|
*
|
||||||
* 设计:所有WS / 历史 / 配额相关副作用全部通过派发事件给 chat 机器处理,
|
* 设计:所有历史 / 配额相关副作用全部通过派发事件给 chat 机器处理,
|
||||||
* chat-screen 不直接创建 ChatWebSocket / 不读 AuthStorage(除 token 取值)。
|
* chat-screen 不直接读 AuthStorage(除 token 取值)。
|
||||||
*/
|
*/
|
||||||
export type ChatEvent =
|
export type ChatEvent =
|
||||||
// 鉴权生命周期(登录态变化后由 ChatAuthSync 派)
|
// 鉴权生命周期(登录态变化后由 ChatAuthSync 派)
|
||||||
| { type: "ChatGuestLogin" }
|
| { type: "ChatGuestLogin" }
|
||||||
| { type: "ChatNonVipLogin"; token: string }
|
| { type: "ChatUserLogin"; token: string }
|
||||||
| { type: "ChatVipLogin"; token: string }
|
|
||||||
| { type: "ChatLogout" }
|
| { type: "ChatLogout" }
|
||||||
// 业务事件
|
// 业务事件
|
||||||
| { type: "ChatSendMessage"; content: string }
|
| { type: "ChatSendMessage"; content: string }
|
||||||
@@ -32,26 +30,4 @@ export type ChatEvent =
|
|||||||
type: "ChatQueuedHttpDone";
|
type: "ChatQueuedHttpDone";
|
||||||
output: import("./chat-machine.helpers").HttpSendOutput;
|
output: import("./chat-machine.helpers").HttpSendOutput;
|
||||||
}
|
}
|
||||||
| { type: "ChatQueuedSendError"; content: string; errorMessage: string }
|
| { type: "ChatQueuedSendError"; content: string; errorMessage: string };
|
||||||
| { type: "ChatImageReceived"; url: string }
|
|
||||||
| {
|
|
||||||
type: "ChatPaywallStatusReceived";
|
|
||||||
lockDetail: {
|
|
||||||
locked: boolean;
|
|
||||||
showContent: boolean;
|
|
||||||
showUpgrade: boolean;
|
|
||||||
reason: string | null;
|
|
||||||
hint: string | null;
|
|
||||||
detail: Record<string, unknown> | null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// WebSocket / AI 推句(由 chat 机器内部 actor 派回 —— "机器自驱动")
|
|
||||||
| {
|
|
||||||
type: "ChatAISentenceReceived";
|
|
||||||
index: number;
|
|
||||||
text: string;
|
|
||||||
total: number;
|
|
||||||
done: boolean;
|
|
||||||
}
|
|
||||||
| { type: "ChatWebSocketError"; errorMessage: string }
|
|
||||||
| { type: "ChatWebSocketConnected"; userId: string };
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Chat 状态机:history / send / websocket / queue actors
|
* Chat 状态机:history / send / queue actors
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fromPromise, fromCallback } from "xstate";
|
import { fromPromise, fromCallback } from "xstate";
|
||||||
|
|
||||||
import { createChatWebSocket, ChatWebSocket } from "@/core/net/chat-websocket";
|
|
||||||
import { MessageQueue } from "@/core/net/message-queue";
|
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";
|
||||||
@@ -20,28 +19,6 @@ import type { ChatEvent } from "./chat-events";
|
|||||||
|
|
||||||
const log = new Logger("StoresChatChatMachineActors");
|
const log = new Logger("StoresChatChatMachineActors");
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 共享 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) vipUserSession 同一时间只可能有一个活跃 WS(parent-level invoke),
|
|
||||||
* module 单例的语义和"VIP 会话内一个活跃连接"一一对应
|
|
||||||
* 3) 不需要重构 ChatWebSocket 类的对外 API
|
|
||||||
*/
|
|
||||||
let activeChatWebSocket: ChatWebSocket | null = null;
|
|
||||||
|
|
||||||
/** 暴露给 `sendMessageWsActor` 读取(测试也可 import) */
|
|
||||||
export function getActiveChatWebSocket(): ChatWebSocket | null {
|
|
||||||
return activeChatWebSocket;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Init 任务 2:拉 history 3 步流(fromPromise,返回最终结果)
|
// Init 任务 2:拉 history 3 步流(fromPromise,返回最终结果)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -131,111 +108,13 @@ export const unlockPrivateMessageActor = fromPromise<
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// WebSocket: long-lived callback
|
|
||||||
// ============================================================
|
|
||||||
/**
|
|
||||||
* 长生命周期 WebSocket(仅 `vipUserSession` 期间 invoke)
|
|
||||||
* - 启动:连 WS,事件 → sendBack 派回机器
|
|
||||||
* - 停止(cleanup):disconnect
|
|
||||||
* - 游客不 invoke 此 actor(业务事实"游客不连 WS")
|
|
||||||
*
|
|
||||||
* 之所以用 fromCallback 而非 fromPromise:
|
|
||||||
* - WS 是长连接,不是一次性 Promise
|
|
||||||
* - fromCallback 提供 `sendBack` 回调,actor 内部事件 → 机器
|
|
||||||
* - cleanup 函数 → XState 在 parent state exit 时自动调用
|
|
||||||
*/
|
|
||||||
export const chatWebSocketActor = fromCallback<ChatEvent, { token: string }>(
|
|
||||||
({ sendBack, input }) => {
|
|
||||||
const ws = createChatWebSocket(input.token);
|
|
||||||
activeChatWebSocket = ws;
|
|
||||||
|
|
||||||
ws.onConnected = (userId) => {
|
|
||||||
log.debug("[chat-machine] WS connected", { userId });
|
|
||||||
sendBack({ type: "ChatWebSocketConnected", userId });
|
|
||||||
};
|
|
||||||
ws.onSentence = (p) => {
|
|
||||||
sendBack({
|
|
||||||
type: "ChatAISentenceReceived",
|
|
||||||
index: p.index,
|
|
||||||
text: p.text,
|
|
||||||
total: p.total,
|
|
||||||
done: p.done,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
ws.onImage = (url) => {
|
|
||||||
sendBack({ type: "ChatImageReceived", url });
|
|
||||||
};
|
|
||||||
ws.onPaywallStatus = (p) => {
|
|
||||||
sendBack({
|
|
||||||
type: "ChatPaywallStatusReceived",
|
|
||||||
lockDetail: p,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
ws.onError = (msg) => {
|
|
||||||
log.error("[chat-machine] WS error", { errorMessage: msg });
|
|
||||||
sendBack({ type: "ChatWebSocketError", errorMessage: msg });
|
|
||||||
};
|
|
||||||
ws.connect();
|
|
||||||
return () => {
|
|
||||||
log.debug("[chat-machine] chatWebSocketActor cleanup");
|
|
||||||
if (activeChatWebSocket === ws) {
|
|
||||||
activeChatWebSocket = null;
|
|
||||||
}
|
|
||||||
ws.disconnect();
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// WebSocket: per-send callback
|
|
||||||
// ============================================================
|
|
||||||
/**
|
|
||||||
* WS 发送消息(vipUserSession.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<void, ...> 稳定(XState v5 已知坑)
|
|
||||||
*/
|
|
||||||
export const sendMessageWsActor = fromPromise<void, { content: string }>(
|
|
||||||
async ({ input }) => {
|
|
||||||
const ws = getActiveChatWebSocket();
|
|
||||||
if (!ws) {
|
|
||||||
log.error("[chat-machine] sendMessageWsActor no active WebSocket");
|
|
||||||
throw new Error("[chat-machine] sendMessageWs: no active WebSocket (actor not running)");
|
|
||||||
}
|
|
||||||
const ok = ws.sendMessage(input.content);
|
|
||||||
if (!ok) {
|
|
||||||
log.error("[chat-machine] sendMessageWsActor WebSocket not OPEN");
|
|
||||||
throw new Error("[chat-machine] sendMessageWs: WebSocket not OPEN");
|
|
||||||
}
|
|
||||||
// resolve 不返值 —— AI reply 由 chatWebSocketActor 的 ChatAISentenceReceived 流回
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const httpMessageQueueActor = fromCallback<ChatEvent>(
|
export const httpMessageQueueActor = fromCallback<ChatEvent>(
|
||||||
({ sendBack, receive }) => {
|
({ sendBack, receive }) => {
|
||||||
return createMessageQueueActor("http", sendBack, receive);
|
return createMessageQueueActor(sendBack, receive);
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const wsPreferredMessageQueueActor = fromCallback<ChatEvent>(
|
|
||||||
({ sendBack, receive }) => {
|
|
||||||
return createMessageQueueActor("wsPreferred", sendBack, receive);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
function createMessageQueueActor(
|
function createMessageQueueActor(
|
||||||
mode: "http" | "wsPreferred",
|
|
||||||
sendBack: (event: ChatEvent) => void,
|
sendBack: (event: ChatEvent) => void,
|
||||||
receive: (listener: (event: ChatEvent) => void) => void,
|
receive: (listener: (event: ChatEvent) => void) => void,
|
||||||
): () => void {
|
): () => void {
|
||||||
@@ -244,14 +123,6 @@ function createMessageQueueActor(
|
|||||||
queue.setConsumer(async (content) => {
|
queue.setConsumer(async (content) => {
|
||||||
sendBack({ type: "ChatQueuedSendStarted" });
|
sendBack({ type: "ChatQueuedSendStarted" });
|
||||||
try {
|
try {
|
||||||
if (mode === "wsPreferred") {
|
|
||||||
const ws = getActiveChatWebSocket();
|
|
||||||
if (ws?.sendMessage(content)) return;
|
|
||||||
log.debug("[chat-machine] message queue fallback to HTTP", {
|
|
||||||
contentLength: content.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = await sendMessageViaHttp(content);
|
const output = await sendMessageViaHttp(content);
|
||||||
sendBack({ type: "ChatQueuedHttpDone", output });
|
sendBack({ type: "ChatQueuedHttpDone", output });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -259,7 +130,6 @@ function createMessageQueueActor(
|
|||||||
error instanceof Error ? error.message : "Message send failed";
|
error instanceof Error ? error.message : "Message send failed";
|
||||||
log.error("[chat-machine] message queue send failed", {
|
log.error("[chat-machine] message queue send failed", {
|
||||||
error,
|
error,
|
||||||
mode,
|
|
||||||
});
|
});
|
||||||
sendBack({
|
sendBack({
|
||||||
type: "ChatQueuedSendError",
|
type: "ChatQueuedSendError",
|
||||||
|
|||||||
+13
-327
@@ -5,34 +5,28 @@
|
|||||||
* chat 机器不感知鉴权 / 不管 WebSocket —— 由 <ChatAuthSync /> 派生 loginStatus
|
* chat 机器不感知鉴权 / 不管 WebSocket —— 由 <ChatAuthSync /> 派生 loginStatus
|
||||||
* ChatAuthSync 派发登录态生命周期事件:
|
* ChatAuthSync 派发登录态生命周期事件:
|
||||||
* - `ChatGuestLogin` → 游客会话(断 WS = 不连)
|
* - `ChatGuestLogin` → 游客会话(断 WS = 不连)
|
||||||
* - `ChatNonVipLogin { token }` → 非 VIP 用户会话(不连 WS)
|
* - `ChatUserLogin { token }` → 其他登录用户会话(不连 WS)
|
||||||
* - `ChatVipLogin { token }` → VIP 用户会话(连 WS)
|
* - `ChatLogout` → 正式登录用户登出
|
||||||
* - `ChatLogout` → 正式登录用户登出(机器自动 cleanup WS actor)
|
|
||||||
*
|
*
|
||||||
* 登录态流转约束:
|
* 登录态流转约束:
|
||||||
* - 未登录:可以进入游客登录 / 其他登录。
|
* - 未登录:可以进入游客登录 / 其他登录。
|
||||||
* - 游客登录:只可以升级为其他登录,不响应退出。
|
* - 游客登录:只可以升级为其他登录,不响应退出。
|
||||||
* - 其他登录:可以退出,也可以在非 VIP / VIP 间切换,不响应游客登录。
|
* - 其他登录:可以退出,不响应游客登录。
|
||||||
*
|
*
|
||||||
* 状态结构(parent state 模式):
|
* 状态结构(parent state 模式):
|
||||||
* - `idle`:屏没挂 / 登出
|
* - `idle`:屏没挂 / 登出
|
||||||
* - `guestSession`(parent):游客会话 —— 不 invoke WS
|
* - `guestSession`(parent):游客会话 —— 不 invoke WS
|
||||||
* - `nonVipUserSession`(parent):非 VIP 用户会话 —— 不 invoke WS
|
* - `userSession`(parent):其他登录用户会话 —— 不 invoke WS
|
||||||
* - `vipUserSession`(parent):VIP 用户会话 —— invoke WS(持久)
|
|
||||||
*
|
*
|
||||||
* init 任务:
|
* init 任务:
|
||||||
* - guestSession.initializing:loadHistory
|
* - guestSession.initializing:loadHistory
|
||||||
* - nonVipUserSession.initializing:loadHistory
|
* - userSession.initializing:loadHistory
|
||||||
* - vipUserSession.initializing:loadHistory + parent-level chatWebSocket 并行
|
|
||||||
* - 每个任务都是独立 actor,不互相等待(除非 `always` barrier)
|
* - 每个任务都是独立 actor,不互相等待(除非 `always` barrier)
|
||||||
*
|
*
|
||||||
* 消息发送路径(业务事实):
|
* 消息发送路径(业务事实):
|
||||||
* - `ChatSendMessage` 只负责即时追加用户消息 + 入队,不进入 sending 子状态
|
* - `ChatSendMessage` 只负责即时追加用户消息 + 入队,不进入 sending 子状态
|
||||||
* - guestSession / nonVipUserSession:队列串行走 HTTP,限制以后端 `lockDetail` 为准
|
* - guestSession / userSession:队列串行走 HTTP,限制以后端 `lockDetail` 为准
|
||||||
* - vipUserSession:队列优先走 WS,WS 不可用时 fallback HTTP
|
|
||||||
* - `pendingReplyCount` 跟踪待回复数量,`isReplyingAI` 由计数派生
|
* - `pendingReplyCount` 跟踪待回复数量,`isReplyingAI` 由计数派生
|
||||||
* - `ChatWebSocketConnected` 事件 → `wsConnected = true`
|
|
||||||
* - `vipUserSession.exit` action → `wsConnected = false`(WS actor cleanup 时也会跑 exit)
|
|
||||||
*
|
*
|
||||||
* 配额:
|
* 配额:
|
||||||
* - 前端不再处理本地消息额度;游客 / 注册用户均以后端响应为准。
|
* - 前端不再处理本地消息额度;游客 / 注册用户均以后端响应为准。
|
||||||
@@ -50,17 +44,13 @@ import {
|
|||||||
applyHttpSendOutput,
|
applyHttpSendOutput,
|
||||||
beginPendingReply,
|
beginPendingReply,
|
||||||
finishPendingReply,
|
finishPendingReply,
|
||||||
normalizeLockDetail,
|
|
||||||
} from "./chat-machine.helpers";
|
} from "./chat-machine.helpers";
|
||||||
import {
|
import {
|
||||||
loadHistoryActor,
|
loadHistoryActor,
|
||||||
sendMessageHttpActor,
|
sendMessageHttpActor,
|
||||||
sendMessageWsActor,
|
|
||||||
loadMoreHistoryActor,
|
loadMoreHistoryActor,
|
||||||
chatWebSocketActor,
|
|
||||||
httpMessageQueueActor,
|
httpMessageQueueActor,
|
||||||
unlockPrivateMessageActor,
|
unlockPrivateMessageActor,
|
||||||
wsPreferredMessageQueueActor,
|
|
||||||
} from "./chat-machine.actors";
|
} from "./chat-machine.actors";
|
||||||
|
|
||||||
const log = new Logger("StoresChatChatMachine");
|
const log = new Logger("StoresChatChatMachine");
|
||||||
@@ -81,11 +71,8 @@ export const chatMachine = setup({
|
|||||||
actors: {
|
actors: {
|
||||||
loadHistory: loadHistoryActor,
|
loadHistory: loadHistoryActor,
|
||||||
sendMessageHttp: sendMessageHttpActor,
|
sendMessageHttp: sendMessageHttpActor,
|
||||||
sendMessageWs: sendMessageWsActor,
|
|
||||||
loadMoreHistory: loadMoreHistoryActor,
|
loadMoreHistory: loadMoreHistoryActor,
|
||||||
chatWebSocket: chatWebSocketActor,
|
|
||||||
httpMessageQueue: httpMessageQueueActor,
|
httpMessageQueue: httpMessageQueueActor,
|
||||||
wsPreferredMessageQueue: wsPreferredMessageQueueActor,
|
|
||||||
unlockPrivateMessage: unlockPrivateMessageActor,
|
unlockPrivateMessage: unlockPrivateMessageActor,
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
@@ -137,7 +124,6 @@ export const chatMachine = setup({
|
|||||||
log.debug("[chat-machine] appendUserMessage", {
|
log.debug("[chat-machine] appendUserMessage", {
|
||||||
contentLength: event.content.length,
|
contentLength: event.content.length,
|
||||||
contentPreview: event.content.slice(0, 50),
|
contentPreview: event.content.slice(0, 50),
|
||||||
wsConnected: context.wsConnected,
|
|
||||||
isReplyingAI: context.isReplyingAI,
|
isReplyingAI: context.isReplyingAI,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -189,7 +175,6 @@ export const chatMachine = setup({
|
|||||||
const today = todayString();
|
const today = todayString();
|
||||||
log.debug("[chat-machine] appendUserImage", {
|
log.debug("[chat-machine] appendUserImage", {
|
||||||
oldMessagesCount: context.messages.length,
|
oldMessagesCount: context.messages.length,
|
||||||
wsConnected: context.wsConnected,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -210,43 +195,6 @@ export const chatMachine = setup({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
appendOrUpdateAISentence: assign(({ context, event }) => {
|
|
||||||
if (event.type !== "ChatAISentenceReceived") return {};
|
|
||||||
const messages = [...context.messages];
|
|
||||||
if (event.index === 0) {
|
|
||||||
messages.push({
|
|
||||||
content: event.text,
|
|
||||||
isFromAI: true,
|
|
||||||
date: todayString(),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const last = messages[messages.length - 1];
|
|
||||||
if (last && last.isFromAI) {
|
|
||||||
messages[messages.length - 1] = {
|
|
||||||
...last,
|
|
||||||
content: `${last.content} ${event.text}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.debug("[chat-machine] appendOrUpdateAISentence", {
|
|
||||||
index: event.index,
|
|
||||||
total: event.total,
|
|
||||||
done: event.done,
|
|
||||||
messagesCount: messages.length,
|
|
||||||
});
|
|
||||||
if (!event.done) {
|
|
||||||
return {
|
|
||||||
messages,
|
|
||||||
pendingReplyCount: Math.max(1, context.pendingReplyCount),
|
|
||||||
isReplyingAI: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
messages,
|
|
||||||
...finishPendingReply(context),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
appendSocketErrorMessage: assign(({ context }) => {
|
appendSocketErrorMessage: assign(({ context }) => {
|
||||||
const messages = [
|
const messages = [
|
||||||
...context.messages,
|
...context.messages,
|
||||||
@@ -266,61 +214,6 @@ export const chatMachine = setup({
|
|||||||
return applyHttpSendOutput(context, event.output);
|
return applyHttpSendOutput(context, event.output);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
appendAIImage: assign(({ context, event }) => {
|
|
||||||
if (event.type !== "ChatImageReceived") return {};
|
|
||||||
const messages = [...context.messages];
|
|
||||||
const last = messages[messages.length - 1];
|
|
||||||
|
|
||||||
if (last?.isFromAI && !last.imageUrl) {
|
|
||||||
messages[messages.length - 1] = {
|
|
||||||
...last,
|
|
||||||
content: "",
|
|
||||||
imageUrl: event.url,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
messages.push({
|
|
||||||
content: "",
|
|
||||||
isFromAI: true,
|
|
||||||
date: todayString(),
|
|
||||||
imageUrl: event.url,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug("[chat-machine] appendAIImage", {
|
|
||||||
messagesCount: messages.length,
|
|
||||||
hasMergedIntoLastMessage: Boolean(last?.isFromAI && !last.imageUrl),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
messages,
|
|
||||||
upgradePromptVisible: false,
|
|
||||||
upgradeReason: null,
|
|
||||||
upgradeHint: null,
|
|
||||||
upgradeDetail: null,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
applyPaywallStatus: assign(({ event }) => {
|
|
||||||
if (event.type !== "ChatPaywallStatusReceived") return {};
|
|
||||||
if (!event.lockDetail.showUpgrade) {
|
|
||||||
return {
|
|
||||||
upgradePromptVisible: false,
|
|
||||||
upgradeReason: null,
|
|
||||||
upgradeHint: null,
|
|
||||||
upgradeDetail: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
upgradePromptVisible: true,
|
|
||||||
upgradeReason:
|
|
||||||
event.lockDetail.reason === "private_message"
|
|
||||||
? "private_message"
|
|
||||||
: "image",
|
|
||||||
upgradeHint: event.lockDetail.hint,
|
|
||||||
upgradeDetail: normalizeLockDetail(event.lockDetail.detail),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
setUnlockingPrivateMessage: assign(({ event }) => {
|
setUnlockingPrivateMessage: assign(({ event }) => {
|
||||||
if (event.type !== "ChatUnlockPrivateMessage") return {};
|
if (event.type !== "ChatUnlockPrivateMessage") return {};
|
||||||
return {
|
return {
|
||||||
@@ -383,9 +276,6 @@ export const chatMachine = setup({
|
|||||||
clearUnlockingPrivateMessage: assign({
|
clearUnlockingPrivateMessage: assign({
|
||||||
unlockingPrivateMessageId: null,
|
unlockingPrivateMessageId: null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setWsConnected: assign({ wsConnected: true }),
|
|
||||||
clearWsConnected: assign({ wsConnected: false }),
|
|
||||||
},
|
},
|
||||||
}).createMachine({
|
}).createMachine({
|
||||||
id: "chat",
|
id: "chat",
|
||||||
@@ -398,12 +288,8 @@ export const chatMachine = setup({
|
|||||||
target: "#chat.guestSession",
|
target: "#chat.guestSession",
|
||||||
actions: "startGuestSession",
|
actions: "startGuestSession",
|
||||||
},
|
},
|
||||||
ChatNonVipLogin: {
|
ChatUserLogin: {
|
||||||
target: "#chat.nonVipUserSession",
|
target: "#chat.userSession",
|
||||||
actions: "startUserSession",
|
|
||||||
},
|
|
||||||
ChatVipLogin: {
|
|
||||||
target: "#chat.vipUserSession",
|
|
||||||
actions: "startUserSession",
|
actions: "startUserSession",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -415,12 +301,8 @@ export const chatMachine = setup({
|
|||||||
src: "httpMessageQueue",
|
src: "httpMessageQueue",
|
||||||
},
|
},
|
||||||
on: {
|
on: {
|
||||||
ChatNonVipLogin: {
|
ChatUserLogin: {
|
||||||
target: "#chat.nonVipUserSession",
|
target: "#chat.userSession",
|
||||||
actions: "startUserSession",
|
|
||||||
},
|
|
||||||
ChatVipLogin: {
|
|
||||||
target: "#chat.vipUserSession",
|
|
||||||
actions: "startUserSession",
|
actions: "startUserSession",
|
||||||
},
|
},
|
||||||
ChatQueuedSendStarted: {
|
ChatQueuedSendStarted: {
|
||||||
@@ -517,7 +399,7 @@ export const chatMachine = setup({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
nonVipUserSession: {
|
userSession: {
|
||||||
invoke: {
|
invoke: {
|
||||||
id: "messageQueue",
|
id: "messageQueue",
|
||||||
src: "httpMessageQueue",
|
src: "httpMessageQueue",
|
||||||
@@ -527,15 +409,11 @@ export const chatMachine = setup({
|
|||||||
target: "#chat.idle",
|
target: "#chat.idle",
|
||||||
actions: "clearChatSession",
|
actions: "clearChatSession",
|
||||||
},
|
},
|
||||||
ChatNonVipLogin: {
|
ChatUserLogin: {
|
||||||
target: "#chat.nonVipUserSession",
|
target: "#chat.userSession",
|
||||||
reenter: true,
|
reenter: true,
|
||||||
actions: "startUserSession",
|
actions: "startUserSession",
|
||||||
},
|
},
|
||||||
ChatVipLogin: {
|
|
||||||
target: "#chat.vipUserSession",
|
|
||||||
actions: "startUserSession",
|
|
||||||
},
|
|
||||||
ChatQueuedSendStarted: {
|
ChatQueuedSendStarted: {
|
||||||
actions: "markQueuedSendStarted",
|
actions: "markQueuedSendStarted",
|
||||||
},
|
},
|
||||||
@@ -644,198 +522,6 @@ export const chatMachine = setup({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
vipUserSession: {
|
|
||||||
// 父级 on:把 WS / AI 流句 事件从 ready.on 上提到 vipUserSession.on
|
|
||||||
// —— 任何 child state(initializing / ready / sendingViaWs / sendingViaHttp / loadingMore)
|
|
||||||
// 都能收到 `ChatAISentenceReceived` 并把 AI 句 push 到 messages。
|
|
||||||
//
|
|
||||||
// 子级 override 规则:
|
|
||||||
// - sendingViaWs 重新声明 `ChatWebSocketError` 加 target: "ready"(带 action)
|
|
||||||
// —— XState v5: child handler 替换 parent;复制 action
|
|
||||||
on: {
|
|
||||||
ChatLogout: {
|
|
||||||
target: "#chat.idle",
|
|
||||||
actions: "clearChatSession",
|
|
||||||
},
|
|
||||||
ChatNonVipLogin: {
|
|
||||||
target: "#chat.nonVipUserSession",
|
|
||||||
actions: "startUserSession",
|
|
||||||
},
|
|
||||||
ChatVipLogin: {
|
|
||||||
target: "#chat.vipUserSession",
|
|
||||||
reenter: true,
|
|
||||||
actions: "startUserSession",
|
|
||||||
},
|
|
||||||
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
|
|
||||||
ChatImageReceived: { actions: "appendAIImage" },
|
|
||||||
ChatPaywallStatusReceived: { actions: "applyPaywallStatus" },
|
|
||||||
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
|
|
||||||
ChatWebSocketConnected: { actions: "setWsConnected" },
|
|
||||||
ChatQueuedSendStarted: {
|
|
||||||
actions: "markQueuedSendStarted",
|
|
||||||
},
|
|
||||||
ChatQueuedHttpDone: {
|
|
||||||
actions: "applyQueuedHttpOutput",
|
|
||||||
},
|
|
||||||
ChatQueuedSendError: {
|
|
||||||
actions: "appendSocketErrorMessage",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
initial: "initializing",
|
|
||||||
exit: "clearWsConnected",
|
|
||||||
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
|
|
||||||
always: [
|
|
||||||
{
|
|
||||||
target: "ready",
|
|
||||||
guard: ({ context }) => context.wsConnected && context.historyLoaded,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
invoke: {
|
|
||||||
// 任务 2:拉 history(只调用 loadHistoryActor,不再用 loadMoreHistoryActor)
|
|
||||||
src: "loadHistory",
|
|
||||||
onDone: {
|
|
||||||
actions: assign(({ event }) => ({
|
|
||||||
messages: event.output.messages,
|
|
||||||
isLoadingMore: false,
|
|
||||||
hasMore: event.output.hasMore,
|
|
||||||
historyOffset: event.output.newOffset,
|
|
||||||
historyLoaded: true,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
onError: {
|
|
||||||
// 失败也标 loaded,不卡 init
|
|
||||||
actions: assign({
|
|
||||||
isLoadingMore: false,
|
|
||||||
historyLoaded: true,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ready: {
|
|
||||||
on: {
|
|
||||||
ChatSendMessage: {
|
|
||||||
guard: ({ event }) => event.content.trim().length > 0,
|
|
||||||
actions: ["appendUserMessage", "enqueueMessage"],
|
|
||||||
},
|
|
||||||
ChatSendImage: {
|
|
||||||
actions: "appendUserImage",
|
|
||||||
},
|
|
||||||
ChatLoadMoreHistory: {
|
|
||||||
target: "loadingMore",
|
|
||||||
},
|
|
||||||
ChatUnlockPrivateMessage: {
|
|
||||||
actions: "setUnlockingPrivateMessage",
|
|
||||||
target: "unlockingPrivate",
|
|
||||||
},
|
|
||||||
// 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected
|
|
||||||
// 已上提到 vipUserSession.on,子级不再声明
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// WS 发送:invoke sendMessageWsActor 触发一次 send,
|
|
||||||
// 之后 AI 流式回 reply 走 vipUserSession.on 的 ChatAISentenceReceived → appendOrUpdateAISentence
|
|
||||||
// 当句尾 done: true → isReplyingAI = false → always 跳回 ready
|
|
||||||
sendingViaWs: {
|
|
||||||
on: {
|
|
||||||
// 流式回包中 WS 出错(mid-stream) → 加错误泡 + 跳回 ready(不卡在 sendingViaWs)
|
|
||||||
// 这个 child handler 替换 vipUserSession.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 }) => ({
|
|
||||||
content: event.type === "ChatSendMessage" ? event.content : "",
|
|
||||||
}),
|
|
||||||
onDone: {
|
|
||||||
target: "ready",
|
|
||||||
actions: assign(({ context, event }) =>
|
|
||||||
applyHttpSendOutput(context, event.output),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
onError: {
|
|
||||||
target: "ready",
|
|
||||||
actions: assign({ isReplyingAI: false }),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
loadingMore: {
|
|
||||||
invoke: {
|
|
||||||
src: "loadMoreHistory",
|
|
||||||
input: ({ context }) => ({ offset: context.historyOffset }),
|
|
||||||
onDone: {
|
|
||||||
target: "ready",
|
|
||||||
actions: assign(({ context, event }) => ({
|
|
||||||
messages: [...event.output.messages, ...context.messages],
|
|
||||||
isLoadingMore: false,
|
|
||||||
hasMore: event.output.hasMore,
|
|
||||||
historyOffset: event.output.newOffset,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
onError: {
|
|
||||||
target: "ready",
|
|
||||||
actions: assign({ isLoadingMore: false }),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
unlockingPrivate: {
|
|
||||||
invoke: {
|
|
||||||
src: "unlockPrivateMessage",
|
|
||||||
input: ({ event }) => ({
|
|
||||||
messageId:
|
|
||||||
event.type === "ChatUnlockPrivateMessage" ? event.messageId : "",
|
|
||||||
}),
|
|
||||||
onDone: {
|
|
||||||
target: "ready",
|
|
||||||
actions: "applyUnlockPrivateOutput",
|
|
||||||
},
|
|
||||||
onError: {
|
|
||||||
target: "ready",
|
|
||||||
actions: "clearUnlockingPrivateMessage",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,6 @@ export interface ChatState {
|
|||||||
messages: UiMessage[];
|
messages: UiMessage[];
|
||||||
isReplyingAI: boolean;
|
isReplyingAI: boolean;
|
||||||
pendingReplyCount: number;
|
pendingReplyCount: number;
|
||||||
/**
|
|
||||||
* WebSocket 连接状态(仅 VIP 用户会话期间 true)
|
|
||||||
* - VIP true:走 WS
|
|
||||||
* - 非 VIP / 游客:固定 false,走 HTTP
|
|
||||||
*/
|
|
||||||
wsConnected: boolean;
|
|
||||||
upgradePromptVisible: boolean;
|
upgradePromptVisible: boolean;
|
||||||
upgradeReason: "daily_limit" | "private_message" | "image" | null;
|
upgradeReason: "daily_limit" | "private_message" | "image" | null;
|
||||||
upgradeHint: string | null;
|
upgradeHint: string | null;
|
||||||
@@ -36,7 +30,6 @@ export const initialState: ChatState = {
|
|||||||
messages: [],
|
messages: [],
|
||||||
isReplyingAI: false,
|
isReplyingAI: false,
|
||||||
pendingReplyCount: 0,
|
pendingReplyCount: 0,
|
||||||
wsConnected: false,
|
|
||||||
upgradePromptVisible: false,
|
upgradePromptVisible: false,
|
||||||
upgradeReason: null,
|
upgradeReason: null,
|
||||||
upgradeHint: null,
|
upgradeHint: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user