refactor(chat): split machine by business flow

This commit is contained in:
2026-07-14 19:04:32 +08:00
parent e48efc36ba
commit 84e677f687
11 changed files with 1403 additions and 1295 deletions
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { createActor, fromCallback, waitFor } from "xstate";
import type { UiMessage } from "@/data/dto/chat";
import type { ChatEvent } from "@/stores/chat/chat-events";
import { chatMachine } from "@/stores/chat/chat-machine";
import { createTestChatMachine } from "./chat-machine.test-utils";
describe("chat history flow", () => {
it("enters user ready after history is loaded", async () => {
const actor = createActor(createTestChatMachine()).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
const snapshot = actor.getSnapshot();
expect(snapshot.context.historyLoaded).toBe(true);
actor.stop();
});
it("renders local history before network history sync finishes", async () => {
let resolveNetwork!: () => void;
const networkReleased = new Promise<void>((resolve) => {
resolveNetwork = resolve;
});
const localMessage: UiMessage = {
id: "local-msg",
content: "cached local message",
isFromAI: true,
date: "2026-07-02",
};
const networkMessage: UiMessage = {
id: "network-msg",
content: "fresh network message",
isFromAI: true,
date: "2026-07-02",
};
const machine = chatMachine.provide({
actors: {
loadHistory: fromCallback<ChatEvent>(({ sendBack }) => {
sendBack({
type: "ChatLocalHistoryLoaded",
output: {
messages: [localMessage],
localCount: 1,
},
});
void networkReleased.then(() => {
sendBack({
type: "ChatNetworkHistoryLoaded",
output: {
messages: [networkMessage],
localOverwritten: true,
localCount: 1,
networkCount: 1,
},
});
});
return () => undefined;
}),
},
});
const actor = createActor(machine).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
expect(actor.getSnapshot().context.messages).toMatchObject([
{ id: "local-msg", content: "cached local message" },
]);
resolveNetwork();
await waitFor(
actor,
(snapshot) => snapshot.context.messages[0]?.id === "network-msg",
);
expect(actor.getSnapshot().context.messages).toMatchObject([
{ id: "network-msg", content: "fresh network message" },
]);
actor.stop();
});
});
@@ -0,0 +1,154 @@
import { fromCallback, fromPromise } from "xstate";
import {
ChatSendResponse,
UnlockPrivateResponse,
type UiMessage,
} from "@/data/dto/chat";
import { chatMachine } from "@/stores/chat/chat-machine";
import type { ChatEvent } from "@/stores/chat/chat-events";
import type {
UnlockMessageOutput as MachineUnlockMessageOutput,
UnlockMessageRequest,
} from "@/stores/chat/chat-machine.helpers";
export interface SendMessageHttpOutput {
response: ChatSendResponse;
reply: UiMessage | null;
}
export interface UnlockHistoryOutput {
unlocked: boolean;
reason: string;
shortfallCredits: number;
messages: UiMessage[];
}
export interface TestUnlockMessageOutput {
messageId: string;
response: UnlockPrivateResponse;
}
export function makeChatSendResponse(): ChatSendResponse {
return ChatSendResponse.from({
reply: "",
audioUrl: "",
messageId: "msg-1",
isGuest: false,
timestamp: Date.now(),
image: { type: null, url: null },
lockDetail: {
locked: false,
reason: null,
},
});
}
export function makeUnlockPrivateResponse(
overrides: Partial<Parameters<typeof UnlockPrivateResponse.from>[0]> = {},
): UnlockPrivateResponse {
return UnlockPrivateResponse.from({
unlocked: true,
content: "unlocked content",
audioUrl: "",
reason: "ok",
creditBalance: 90,
creditsCharged: 10,
requiredCredits: 10,
shortfallCredits: 0,
...overrides,
});
}
export function createTestChatMachine(
options: {
historyMessages?: UiMessage[];
sendMessageHttpError?: Error;
unlockHistoryOutput?: UnlockHistoryOutput;
unlockHistoryError?: Error;
unlockMessageOutput?: TestUnlockMessageOutput;
} = {},
) {
return chatMachine.provide({
actors: {
loadHistory: createLoadHistoryCallback(options.historyMessages ?? []),
sendMessageHttp: fromPromise<
SendMessageHttpOutput,
{ content: string }
>(async () => {
if (options.sendMessageHttpError) {
throw options.sendMessageHttpError;
}
return {
response: makeChatSendResponse(),
reply: null,
};
}),
httpMessageQueue: fromCallback<ChatEvent>(({ receive, sendBack }) => {
receive((event) => {
if (event.type !== "ChatSendMessage") return;
sendBack({ type: "ChatQueuedSendStarted" });
sendBack({
type: "ChatQueuedHttpDone",
output: {
response: makeChatSendResponse(),
reply: null,
},
});
});
return () => undefined;
}),
unlockHistory: fromPromise<UnlockHistoryOutput>(async () => {
if (options.unlockHistoryError) {
throw options.unlockHistoryError;
}
return {
unlocked: true,
reason: "ok",
shortfallCredits: 0,
messages: [],
...options.unlockHistoryOutput,
};
}),
unlockMessage: fromPromise<
MachineUnlockMessageOutput,
UnlockMessageRequest
>(async ({ input }) => {
const output = options.unlockMessageOutput ?? {
messageId: input.messageId ?? input.displayMessageId,
response: makeUnlockPrivateResponse(),
};
return {
displayMessageId: input.displayMessageId,
request: input,
response: output.response,
};
}),
},
});
}
export function createLoadHistoryCallback(
messages: UiMessage[],
networkMessages: UiMessage[] = messages,
) {
return fromCallback<ChatEvent>(({ sendBack }) => {
sendBack({
type: "ChatLocalHistoryLoaded",
output: {
messages,
localCount: messages.length,
},
});
sendBack({
type: "ChatNetworkHistoryLoaded",
output: {
messages: networkMessages,
localOverwritten: true,
localCount: messages.length,
networkCount: networkMessages.length,
},
});
return () => undefined;
});
}
@@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
import { createActor, fromCallback, fromPromise, waitFor } from "xstate";
import type { ChatEvent } from "@/stores/chat/chat-events";
import { chatMachine } from "@/stores/chat/chat-machine";
import {
createLoadHistoryCallback,
createTestChatMachine,
makeChatSendResponse,
type SendMessageHttpOutput,
type UnlockHistoryOutput,
} from "./chat-machine.test-utils";
describe("chat send flow", () => {
it("allows multiple messages to be queued without leaving ready state", async () => {
const actor = createActor(createTestChatMachine()).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
actor.send({ type: "ChatSendMessage", content: "hello" });
actor.send({ type: "ChatSendMessage", content: "still there?" });
expect(actor.getSnapshot().matches({ userSession: "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();
});
it("applies direct HTTP actor output with a type-bound action", async () => {
const actor = createActor(createTestChatMachine()).start();
actor.send({ type: "ChatGuestLogin" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ guestSession: "ready" }),
);
actor.send({
type: "ChatSendImage",
imageBase64: "data:image/png;base64,image",
});
await waitFor(actor, (snapshot) =>
snapshot.matches({ guestSession: "ready" }) &&
snapshot.context.pendingReplyCount === 0,
);
expect(actor.getSnapshot().context.isReplyingAI).toBe(false);
expect(actor.getSnapshot().context.messages).toMatchObject([
{ content: "[Image]", isFromAI: false },
]);
actor.stop();
});
it("restores ready state when the direct HTTP actor fails", async () => {
const actor = createActor(
createTestChatMachine({
sendMessageHttpError: new Error("send failed"),
}),
).start();
actor.send({ type: "ChatGuestLogin" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ guestSession: "ready" }),
);
actor.send({
type: "ChatSendImage",
imageBase64: "data:image/png;base64,image",
});
await waitFor(actor, (snapshot) =>
snapshot.matches({ guestSession: "ready" }),
);
expect(actor.getSnapshot().context.isReplyingAI).toBe(false);
actor.stop();
});
it("tracks replying state by queued batch instead of user message count", async () => {
const machine = chatMachine.provide({
actors: {
loadHistory: createLoadHistoryCallback([]),
sendMessageHttp: fromPromise<
SendMessageHttpOutput,
{ content: string }
>(async () => ({
response: makeChatSendResponse(),
reply: null,
})),
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;
}),
unlockHistory: fromPromise<UnlockHistoryOutput>(async () => ({
unlocked: true,
reason: "ok",
shortfallCredits: 0,
messages: [],
})),
},
});
const actor = createActor(machine).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "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();
});
});
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import { createActor, waitFor } from "xstate";
import { createTestChatMachine } from "./chat-machine.test-utils";
describe("chat session flow", () => {
it("keeps guest logout disabled and supports user to guest transition", async () => {
const actor = createActor(createTestChatMachine()).start();
actor.send({ type: "ChatGuestLogin" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ guestSession: "ready" }),
);
actor.send({ type: "ChatLogout" });
expect(actor.getSnapshot().matches({ guestSession: "ready" })).toBe(true);
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
actor.send({ type: "ChatGuestLogin" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ guestSession: "ready" }),
);
actor.stop();
});
it("allows explicit logout from user session", async () => {
const actor = createActor(createTestChatMachine()).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
actor.send({ type: "ChatLogout" });
expect(actor.getSnapshot().matches("idle")).toBe(true);
actor.stop();
});
it("keeps a promotion separate from normal history", async () => {
const actor = createActor(
createTestChatMachine({
historyMessages: [
{
id: "history-1",
content: "Existing history",
isFromAI: true,
date: "2026-07-13",
},
],
}),
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
actor.send({
type: "ChatPromotionInjected",
promotion: {
promotionType: "voice",
lockType: "voice_message",
clientLockId: "promotion-1",
createdAt: 1,
},
});
const context = actor.getSnapshot().context;
expect(context.messages).toHaveLength(1);
expect(context.promotion?.message).toMatchObject({
id: "promotion:promotion-1",
locked: true,
lockReason: "voice_message",
});
actor.stop();
});
it("switches to guest session when guest login arrives during user session", async () => {
const actor = createActor(createTestChatMachine()).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
actor.send({ type: "ChatGuestLogin" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ guestSession: "ready" }),
);
actor.stop();
});
it("ignores repeated user login while an authenticated user session is active", async () => {
const actor = createActor(createTestChatMachine()).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
actor.send({ type: "ChatSendMessage", content: "keep this message" });
await waitFor(
actor,
(snapshot) => snapshot.context.messages.length === 1,
);
actor.send({ type: "ChatUserLogin", token: "another-token" });
expect(actor.getSnapshot().matches({ userSession: "ready" })).toBe(true);
expect(actor.getSnapshot().context.messages).toMatchObject([
{ content: "keep this message", isFromAI: false },
]);
actor.stop();
});
});
@@ -1,490 +1,14 @@
import { describe, expect, it } from "vitest";
import { createActor, fromCallback, fromPromise, waitFor } from "xstate";
import { createActor, waitFor } from "xstate";
import { ChatSendResponse } from "@/data/dto/chat";
import {
ChatSendResponse,
UnlockPrivateResponse,
type UiMessage,
} from "@/data/dto/chat";
import { chatMachine } from "@/stores/chat/chat-machine";
import type { ChatEvent } from "@/stores/chat/chat-events";
import type {
UnlockMessageOutput as MachineUnlockMessageOutput,
UnlockMessageRequest,
} from "@/stores/chat/chat-machine.helpers";
interface SendMessageHttpOutput {
response: ChatSendResponse;
reply: UiMessage | null;
}
interface UnlockHistoryOutput {
unlocked: boolean;
reason: string;
shortfallCredits: number;
messages: UiMessage[];
}
interface TestUnlockMessageOutput {
messageId: string;
response: UnlockPrivateResponse;
}
function makeChatSendResponse(): ChatSendResponse {
return ChatSendResponse.from({
reply: "",
audioUrl: "",
messageId: "msg-1",
isGuest: false,
timestamp: Date.now(),
image: { type: null, url: null },
lockDetail: {
locked: false,
reason: null,
},
});
}
function makeUnlockPrivateResponse(
overrides: Partial<Parameters<typeof UnlockPrivateResponse.from>[0]> = {},
): UnlockPrivateResponse {
return UnlockPrivateResponse.from({
unlocked: true,
content: "unlocked content",
audioUrl: "",
reason: "ok",
creditBalance: 90,
creditsCharged: 10,
requiredCredits: 10,
shortfallCredits: 0,
...overrides,
});
}
function createTestChatMachine(
options: {
historyMessages?: UiMessage[];
sendMessageHttpError?: Error;
unlockHistoryOutput?: UnlockHistoryOutput;
unlockHistoryError?: Error;
unlockMessageOutput?: TestUnlockMessageOutput;
} = {},
) {
return chatMachine.provide({
actors: {
loadHistory: createLoadHistoryCallback(options.historyMessages ?? []),
sendMessageHttp: fromPromise<
SendMessageHttpOutput,
{ content: string }
>(async () => {
if (options.sendMessageHttpError) {
throw options.sendMessageHttpError;
}
return {
response: makeChatSendResponse(),
reply: null,
};
}),
httpMessageQueue: fromCallback<ChatEvent>(({ receive, sendBack }) => {
receive((event) => {
if (event.type !== "ChatSendMessage") return;
sendBack({ type: "ChatQueuedSendStarted" });
sendBack({
type: "ChatQueuedHttpDone",
output: {
response: makeChatSendResponse(),
reply: null,
},
});
});
return () => undefined;
}),
unlockHistory: fromPromise<UnlockHistoryOutput>(async () => {
if (options.unlockHistoryError) {
throw options.unlockHistoryError;
}
return {
unlocked: true,
reason: "ok",
shortfallCredits: 0,
messages: [],
...options.unlockHistoryOutput,
};
}),
unlockMessage: fromPromise<
MachineUnlockMessageOutput,
UnlockMessageRequest
>(async ({ input }) => {
const output = options.unlockMessageOutput ?? {
messageId: input.messageId ?? input.displayMessageId,
response: makeUnlockPrivateResponse(),
};
return {
displayMessageId: input.displayMessageId,
request: input,
response: output.response,
};
}),
},
});
}
function createLoadHistoryCallback(
messages: UiMessage[],
networkMessages: UiMessage[] = messages,
) {
return fromCallback<ChatEvent>(({ sendBack }) => {
sendBack({
type: "ChatLocalHistoryLoaded",
output: {
messages,
localCount: messages.length,
},
});
sendBack({
type: "ChatNetworkHistoryLoaded",
output: {
messages: networkMessages,
localOverwritten: true,
localCount: messages.length,
networkCount: networkMessages.length,
},
});
return () => undefined;
});
}
describe("chatMachine transitions", () => {
it("keeps guest logout disabled and supports user to guest transition", async () => {
const actor = createActor(createTestChatMachine()).start();
actor.send({ type: "ChatGuestLogin" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ guestSession: "ready" }),
);
actor.send({ type: "ChatLogout" });
expect(actor.getSnapshot().matches({ guestSession: "ready" })).toBe(true);
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
actor.send({ type: "ChatGuestLogin" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ guestSession: "ready" }),
);
actor.stop();
});
it("allows explicit logout from user session", async () => {
const actor = createActor(createTestChatMachine()).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
actor.send({ type: "ChatLogout" });
expect(actor.getSnapshot().matches("idle")).toBe(true);
actor.stop();
});
it("enters user ready after history is loaded", async () => {
const actor = createActor(createTestChatMachine()).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
const snapshot = actor.getSnapshot();
expect(snapshot.context.historyLoaded).toBe(true);
actor.stop();
});
it("keeps a promotion separate from normal history", async () => {
const actor = createActor(
createTestChatMachine({
historyMessages: [
{
id: "history-1",
content: "Existing history",
isFromAI: true,
date: "2026-07-13",
},
],
}),
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
actor.send({
type: "ChatPromotionInjected",
promotion: {
promotionType: "voice",
lockType: "voice_message",
clientLockId: "promotion-1",
createdAt: 1,
},
});
const context = actor.getSnapshot().context;
expect(context.messages).toHaveLength(1);
expect(context.promotion?.message).toMatchObject({
id: "promotion:promotion-1",
locked: true,
lockReason: "voice_message",
});
actor.stop();
});
it("renders local history before network history sync finishes", async () => {
let resolveNetwork!: () => void;
const networkReleased = new Promise<void>((resolve) => {
resolveNetwork = resolve;
});
const localMessage: UiMessage = {
id: "local-msg",
content: "cached local message",
isFromAI: true,
date: "2026-07-02",
};
const networkMessage: UiMessage = {
id: "network-msg",
content: "fresh network message",
isFromAI: true,
date: "2026-07-02",
};
const machine = chatMachine.provide({
actors: {
loadHistory: fromCallback<ChatEvent>(({ sendBack }) => {
sendBack({
type: "ChatLocalHistoryLoaded",
output: {
messages: [localMessage],
localCount: 1,
},
});
void networkReleased.then(() => {
sendBack({
type: "ChatNetworkHistoryLoaded",
output: {
messages: [networkMessage],
localOverwritten: true,
localCount: 1,
networkCount: 1,
},
});
});
return () => undefined;
}),
},
});
const actor = createActor(machine).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
expect(actor.getSnapshot().context.messages).toMatchObject([
{ id: "local-msg", content: "cached local message" },
]);
resolveNetwork();
await waitFor(
actor,
(snapshot) => snapshot.context.messages[0]?.id === "network-msg",
);
expect(actor.getSnapshot().context.messages).toMatchObject([
{ id: "network-msg", content: "fresh network message" },
]);
actor.stop();
});
it("switches to guest session when guest login arrives during user session", async () => {
const actor = createActor(createTestChatMachine()).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
actor.send({ type: "ChatGuestLogin" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ guestSession: "ready" }),
);
actor.stop();
});
it("ignores repeated user login while an authenticated user session is active", async () => {
const actor = createActor(createTestChatMachine()).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
actor.send({ type: "ChatSendMessage", content: "keep this message" });
await waitFor(
actor,
(snapshot) => snapshot.context.messages.length === 1,
);
actor.send({ type: "ChatUserLogin", token: "another-token" });
expect(actor.getSnapshot().matches({ userSession: "ready" })).toBe(true);
expect(actor.getSnapshot().context.messages).toMatchObject([
{ content: "keep this message", isFromAI: false },
]);
actor.stop();
});
it("allows multiple messages to be queued without leaving ready state", async () => {
const actor = createActor(createTestChatMachine()).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
actor.send({ type: "ChatSendMessage", content: "hello" });
actor.send({ type: "ChatSendMessage", content: "still there?" });
expect(actor.getSnapshot().matches({ userSession: "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();
});
it("applies direct HTTP actor output with a type-bound action", async () => {
const actor = createActor(createTestChatMachine()).start();
actor.send({ type: "ChatGuestLogin" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ guestSession: "ready" }),
);
actor.send({
type: "ChatSendImage",
imageBase64: "data:image/png;base64,image",
});
await waitFor(actor, (snapshot) =>
snapshot.matches({ guestSession: "ready" }) &&
snapshot.context.pendingReplyCount === 0,
);
expect(actor.getSnapshot().context.isReplyingAI).toBe(false);
expect(actor.getSnapshot().context.messages).toMatchObject([
{ content: "[Image]", isFromAI: false },
]);
actor.stop();
});
it("restores ready state when the direct HTTP actor fails", async () => {
const actor = createActor(
createTestChatMachine({
sendMessageHttpError: new Error("send failed"),
}),
).start();
actor.send({ type: "ChatGuestLogin" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ guestSession: "ready" }),
);
actor.send({
type: "ChatSendImage",
imageBase64: "data:image/png;base64,image",
});
await waitFor(actor, (snapshot) =>
snapshot.matches({ guestSession: "ready" }),
);
expect(actor.getSnapshot().context.isReplyingAI).toBe(false);
actor.stop();
});
it("tracks replying state by queued batch instead of user message count", async () => {
const machine = chatMachine.provide({
actors: {
loadHistory: createLoadHistoryCallback([]),
sendMessageHttp: fromPromise<
SendMessageHttpOutput,
{ content: string }
>(async () => ({
response: makeChatSendResponse(),
reply: null,
})),
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;
}),
unlockHistory: fromPromise<UnlockHistoryOutput>(async () => ({
unlocked: true,
reason: "ok",
shortfallCredits: 0,
messages: [],
})),
},
});
const actor = createActor(machine).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "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();
});
createTestChatMachine,
makeUnlockPrivateResponse,
} from "./chat-machine.test-utils";
describe("chat unlock flow", () => {
it("prompts before unlocking multiple locked history messages after payment", async () => {
const actor = createActor(
createTestChatMachine({
+5 -812
View File
@@ -1,824 +1,17 @@
import {
setup,
type ActionFunction,
type DoneActorEvent,
type ErrorActorEvent,
type EventObject,
} from "xstate";
chatMachineSetup,
chatRootStateConfig,
} from "./machine/session-flow";
import { initialState } from "./chat-state";
import { todayString } from "@/utils/date";
import { Logger } from "@/utils/logger";
import type { ChatEvent } from "./chat-events";
import {
httpMessageQueueActor,
loadHistoryActor,
sendMessageHttpActor,
unlockHistoryActor,
unlockMessageActor,
} from "./chat-machine.actors";
import {
applyHistoryLoadedOutput,
applyHttpSendOutput,
applyNetworkHistoryLoadedOutput,
applyPromotionUnlockOutput,
applySingleUnlockOutput,
beginPendingReply,
countLockedHistoryMessages,
createChatPromotionState,
finishPendingReply,
shouldPromptUnlockHistory,
type HttpSendOutput,
type UnlockMessageOutput,
} from "./chat-machine.helpers";
import { initialState, type ChatState } from "./chat-state";
import type { UnlockHistoryOutput } from "./chat-unlock-flow";
// 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变
export type { ChatState } from "./chat-state";
export { initialState } from "./chat-state";
export type { ChatEvent } from "./chat-events";
const baseChatMachineSetup = setup({
types: {
context: {} as ChatState,
events: {} as ChatEvent,
},
actors: {
loadHistory: loadHistoryActor,
sendMessageHttp: sendMessageHttpActor,
httpMessageQueue: httpMessageQueueActor,
unlockHistory: unlockHistoryActor,
unlockMessage: unlockMessageActor,
},
});
const sendLog = new Logger("StoresChatChatSendFlow");
const mediaLog = new Logger("StoresChatChatMediaFlow");
const enqueueMessageAction = baseChatMachineSetup.sendTo(
"messageQueue",
({ event }) => event,
);
const startGuestSessionAction = baseChatMachineSetup.assign(
({ context }) => ({
...initialState,
promotion: context.promotion,
}),
);
const startUserSessionAction = baseChatMachineSetup.assign(
({ context }) => ({
...initialState,
promotion: context.promotion,
}),
);
const clearChatSessionAction = baseChatMachineSetup.assign(() => ({
...initialState,
}));
const injectPromotionAction = baseChatMachineSetup.assign(({ event }) => {
if (event.type !== "ChatPromotionInjected") return {};
return {
promotion: createChatPromotionState(
event.promotion,
event.messageId,
),
};
});
const clearPromotionAction = baseChatMachineSetup.assign({ promotion: null });
const applyLocalHistoryLoadedAction = baseChatMachineSetup.assign(
({ event }) => {
if (event.type !== "ChatLocalHistoryLoaded") return {};
return applyHistoryLoadedOutput(event.output);
},
);
const applyNetworkHistoryLoadedAction = baseChatMachineSetup.assign(
({ context, event }) => {
if (event.type !== "ChatNetworkHistoryLoaded") return {};
return applyNetworkHistoryLoadedOutput(context, event.output);
},
);
const applyNetworkHistoryLoadedAndClearPaymentAction =
baseChatMachineSetup.assign(({ context, event }) => {
if (event.type !== "ChatNetworkHistoryLoaded") return {};
return {
...applyNetworkHistoryLoadedOutput(context, event.output),
paymentUnlockPending: false,
};
});
const showUnlockHistoryPromptFromNetworkAction = baseChatMachineSetup.assign(
({ context, event }) => {
if (event.type !== "ChatNetworkHistoryLoaded") return {};
const nextHistory = applyNetworkHistoryLoadedOutput(context, event.output);
return {
...nextHistory,
paymentUnlockPending: false,
unlockHistoryPromptVisible: true,
lockedHistoryCount: countLockedHistoryMessages(nextHistory.messages),
unlockHistoryError: null,
};
},
);
const markHistoryLoadFailedAction = baseChatMachineSetup.assign({
historyLoaded: true,
});
const appendGuestUserMessageAction = baseChatMachineSetup.assign(
({ context, event }) => {
if (event.type !== "ChatSendMessage") return {};
const today = todayString();
sendLog.debug("[chat-machine] appendGuestUserMessage", {
contentLength: event.content.length,
contentPreview: event.content.slice(0, 50),
quotaMode: "server controlled",
isReplyingAI: context.isReplyingAI,
});
return {
messages: [
...context.messages,
{
content: event.content,
isFromAI: false,
date: today,
},
],
upgradePromptVisible: false,
upgradeReason: null,
};
},
);
const appendUserMessageAction = baseChatMachineSetup.assign(
({ context, event }) => {
if (event.type !== "ChatSendMessage") return {};
const today = todayString();
sendLog.debug("[chat-machine] appendUserMessage", {
contentLength: event.content.length,
contentPreview: event.content.slice(0, 50),
isReplyingAI: context.isReplyingAI,
});
return {
messages: [
...context.messages,
{
content: event.content,
isFromAI: false,
date: today,
},
],
upgradePromptVisible: false,
upgradeReason: null,
};
},
);
const appendQueuedSendErrorMessageAction = baseChatMachineSetup.assign(
({ context }) => {
const messages = [
...context.messages,
{
content: "Something went wrong. Try sending again?",
isFromAI: true,
date: todayString(),
},
];
return { messages, ...finishPendingReply(context) };
},
);
const markQueuedSendStartedAction = baseChatMachineSetup.assign(
({ context }) => beginPendingReply(context),
);
const applyQueuedHttpOutputAction = baseChatMachineSetup.assign(
({ context, event }) => {
if (event.type !== "ChatQueuedHttpDone") return {};
return applyHttpSendOutput(context, event.output);
},
);
const clearUpgradePromptAction = baseChatMachineSetup.assign(() => ({
upgradePromptVisible: false,
upgradeReason: null,
canSendMessage: true,
requiredCredits: 0,
shortfallCredits: 0,
}));
const appendGuestUserImageAction = baseChatMachineSetup.assign(
({ context, event }) => {
if (event.type !== "ChatSendImage") return {};
const today = todayString();
mediaLog.debug("[chat-machine] appendGuestUserImage", {
oldMessagesCount: context.messages.length,
quotaMode: "server controlled",
});
return {
messages: [
...context.messages,
{
content: "[Image]",
isFromAI: false,
date: today,
imageUrl: event.imageBase64,
},
],
...beginPendingReply(context),
upgradePromptVisible: false,
upgradeReason: null,
};
},
);
const appendUserImageAction = baseChatMachineSetup.assign(
({ context, event }) => {
if (event.type !== "ChatSendImage") return {};
const today = todayString();
mediaLog.debug("[chat-machine] appendUserImage", {
oldMessagesCount: context.messages.length,
});
return {
messages: [
...context.messages,
{
content: "[Image]",
isFromAI: false,
date: today,
imageUrl: event.imageBase64,
},
],
...beginPendingReply(context),
upgradePromptVisible: false,
upgradeReason: null,
};
},
);
const markPaymentUnlockPendingAction = baseChatMachineSetup.assign(() => ({
paymentUnlockPending: true,
unlockHistoryError: null,
}));
const showUnlockHistoryPromptAction = baseChatMachineSetup.assign(
({ context }) => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: true,
lockedHistoryCount: countLockedHistoryMessages(context.messages),
unlockHistoryError: null,
}),
);
const dismissUnlockHistoryPromptAction = baseChatMachineSetup.assign(() => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
unlockHistoryError: null,
}));
const markUnlockHistoryStartedAction = baseChatMachineSetup.assign(() => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
isUnlockingHistory: true,
unlockHistoryError: null,
}));
const markUnlockHistoryFailedAction = baseChatMachineSetup.assign(() => ({
isUnlockingHistory: false,
unlockHistoryPromptVisible: true,
unlockHistoryError: "Failed to unlock messages. Please try again.",
}));
const markUnlockMessageStartedAction = baseChatMachineSetup.assign(
({ event }) => {
if (event.type !== "ChatUnlockMessageRequested") return {};
return {
isUnlockingMessage: true,
unlockingMessageId: event.messageId,
unlockingMessageKind: event.kind,
unlockingRemoteMessageId:
event.remoteMessageId ?? (event.lockType ? null : event.messageId),
unlockingLockType: event.lockType ?? null,
unlockingClientLockId: event.clientLockId ?? null,
unlockMessageError: null,
unlockPaywallRequest: null,
};
},
);
const applyUnlockMessageSucceededAction = baseChatMachineSetup.assign(
({ context, event }) => {
const { output } = event as unknown as DoneActorEvent<UnlockMessageOutput>;
return {
messages: applySingleUnlockOutput(context.messages, output),
promotion: applyPromotionUnlockOutput(context.promotion, output),
isUnlockingMessage: false,
unlockingMessageId: null,
unlockingMessageKind: null,
unlockingRemoteMessageId: null,
unlockingLockType: null,
unlockingClientLockId: null,
unlockMessageError: null,
unlockPaywallRequest: null,
creditBalance: output.response.creditBalance,
creditsCharged: output.response.creditsCharged,
requiredCredits: 0,
shortfallCredits: 0,
};
},
);
const requestUnlockPaymentFromOutputAction = baseChatMachineSetup.assign(
({ context, event }) => {
const { output } = event as unknown as DoneActorEvent<UnlockMessageOutput>;
return {
promotion: applyPromotionUnlockOutput(context.promotion, output),
isUnlockingMessage: false,
unlockingMessageId: null,
unlockingMessageKind: null,
unlockingRemoteMessageId: null,
unlockingLockType: null,
unlockingClientLockId: null,
unlockMessageError: output.response.reason,
unlockPaywallRequest: {
displayMessageId:
output.response.messageId || output.displayMessageId,
...(output.response.messageId || output.request.messageId
? {
messageId:
output.response.messageId || output.request.messageId,
}
: {}),
kind: context.unlockingMessageKind ?? "private",
...(output.request.lockType
? { lockType: output.request.lockType }
: {}),
...(output.request.clientLockId
? { clientLockId: output.request.clientLockId }
: {}),
...(context.promotion?.message.id === output.displayMessageId
? { promotion: context.promotion.session }
: {}),
reason: output.response.reason,
creditBalance: output.response.creditBalance,
requiredCredits: output.response.requiredCredits,
shortfallCredits: output.response.shortfallCredits,
},
creditBalance: output.response.creditBalance,
requiredCredits: output.response.requiredCredits,
shortfallCredits: output.response.shortfallCredits,
};
},
);
const requestUnlockPaymentFromErrorAction = baseChatMachineSetup.assign(
({ context }) => ({
isUnlockingMessage: false,
unlockMessageError: "unlock_failed",
unlockPaywallRequest:
context.unlockingMessageId && context.unlockingMessageKind
? {
displayMessageId: context.unlockingMessageId,
...(context.unlockingRemoteMessageId
? { messageId: context.unlockingRemoteMessageId }
: {}),
kind: context.unlockingMessageKind,
...(context.unlockingLockType
? { lockType: context.unlockingLockType }
: {}),
...(context.unlockingClientLockId
? { clientLockId: context.unlockingClientLockId }
: {}),
...(context.promotion?.message.id === context.unlockingMessageId
? { promotion: context.promotion.session }
: {}),
reason: "unlock_failed",
creditBalance: context.creditBalance,
requiredCredits: context.requiredCredits,
shortfallCredits: context.shortfallCredits,
}
: null,
unlockingMessageId: null,
unlockingMessageKind: null,
unlockingRemoteMessageId: null,
unlockingLockType: null,
unlockingClientLockId: null,
}),
);
const clearUnlockPaywallRequestAction = baseChatMachineSetup.assign(() => ({
unlockPaywallRequest: null,
}));
type ChatActorAction<TEvent extends EventObject> = ActionFunction<
ChatState,
TEvent,
ChatEvent,
undefined,
never,
never,
never,
never,
never
>;
function createChatActorActionSetup<TEvent extends EventObject>() {
const actorActionSetup = setup({
types: {
context: {} as ChatState,
events: {} as TEvent,
},
});
return {
assign(
assignment: Parameters<typeof actorActionSetup.assign>[0],
): ChatActorAction<TEvent> {
// Actor lifecycle events are expression events, not public machine events.
return actorActionSetup.assign(assignment) as unknown as ChatActorAction<TEvent>;
},
};
}
const sendMessageDoneActionSetup =
createChatActorActionSetup<DoneActorEvent<HttpSendOutput>>();
const unlockHistoryDoneActionSetup =
createChatActorActionSetup<DoneActorEvent<UnlockHistoryOutput>>();
const actorErrorActionSetup = createChatActorActionSetup<ErrorActorEvent>();
const applyHttpSendOutputAction = sendMessageDoneActionSetup.assign(
({ context, event }) => applyHttpSendOutput(context, event.output),
);
const markHttpSendFailedAction = actorErrorActionSetup.assign({
isReplyingAI: false,
});
const applyUnlockHistoryOutputAction =
unlockHistoryDoneActionSetup.assign(({ event }) => {
const lockedHistoryCount = countLockedHistoryMessages(
event.output.messages,
);
const insufficientBalance =
!event.output.unlocked &&
event.output.reason === "insufficient_balance";
return {
messages: event.output.messages,
historyLoaded: true,
paymentUnlockPending: false,
unlockHistoryPromptVisible: insufficientBalance,
lockedHistoryCount,
isUnlockingHistory: false,
unlockHistoryError: insufficientBalance
? `Insufficient credits. You still need ${event.output.shortfallCredits} credits.`
: null,
};
});
const chatMachineSetup = baseChatMachineSetup.extend({
actions: {
enqueueMessage: enqueueMessageAction,
startGuestSession: startGuestSessionAction,
startUserSession: startUserSessionAction,
clearChatSession: clearChatSessionAction,
injectPromotion: injectPromotionAction,
clearPromotion: clearPromotionAction,
applyLocalHistoryLoaded: applyLocalHistoryLoadedAction,
applyNetworkHistoryLoaded: applyNetworkHistoryLoadedAction,
applyNetworkHistoryLoadedAndClearPayment:
applyNetworkHistoryLoadedAndClearPaymentAction,
showUnlockHistoryPromptFromNetwork:
showUnlockHistoryPromptFromNetworkAction,
markHistoryLoadFailed: markHistoryLoadFailedAction,
appendGuestUserMessage: appendGuestUserMessageAction,
appendUserMessage: appendUserMessageAction,
appendQueuedSendErrorMessage: appendQueuedSendErrorMessageAction,
markQueuedSendStarted: markQueuedSendStartedAction,
applyQueuedHttpOutput: applyQueuedHttpOutputAction,
clearUpgradePrompt: clearUpgradePromptAction,
appendGuestUserImage: appendGuestUserImageAction,
appendUserImage: appendUserImageAction,
markPaymentUnlockPending: markPaymentUnlockPendingAction,
showUnlockHistoryPrompt: showUnlockHistoryPromptAction,
dismissUnlockHistoryPrompt: dismissUnlockHistoryPromptAction,
markUnlockHistoryStarted: markUnlockHistoryStartedAction,
markUnlockHistoryFailed: markUnlockHistoryFailedAction,
markUnlockMessageStarted: markUnlockMessageStartedAction,
applyUnlockMessageSucceeded: applyUnlockMessageSucceededAction,
requestUnlockPaymentFromOutput: requestUnlockPaymentFromOutputAction,
requestUnlockPaymentFromError: requestUnlockPaymentFromErrorAction,
clearUnlockPaywallRequest: clearUnlockPaywallRequestAction,
},
});
export const chatMachine = chatMachineSetup.createMachine({
id: "chat",
initial: "idle",
context: initialState,
on: {
ChatPromotionInjected: { actions: "injectPromotion" },
ChatPromotionCleared: { actions: "clearPromotion" },
},
states: {
idle: {
on: {
ChatGuestLogin: {
target: "#chat.guestSession",
actions: "startGuestSession",
},
ChatUserLogin: {
target: "#chat.userSession",
actions: "startUserSession",
},
},
},
guestSession: {
invoke: [
{
id: "messageQueue",
src: "httpMessageQueue",
},
{
id: "loadHistory",
src: "loadHistory",
},
],
on: {
ChatUserLogin: {
target: "#chat.userSession",
actions: "startUserSession",
},
ChatNetworkHistoryLoaded: {
actions: "applyNetworkHistoryLoaded",
},
ChatHistoryLoadFailed: {
actions: "markHistoryLoadFailed",
},
ChatQueuedSendStarted: {
actions: "markQueuedSendStarted",
},
ChatQueuedHttpDone: {
actions: "applyQueuedHttpOutput",
},
ChatQueuedSendError: {
actions: "appendQueuedSendErrorMessage",
},
},
initial: "initializing",
states: {
initializing: {
on: {
ChatLocalHistoryLoaded: {
target: "ready",
actions: "applyLocalHistoryLoaded",
},
ChatNetworkHistoryLoaded: {
target: "ready",
actions: "applyNetworkHistoryLoaded",
},
ChatHistoryLoadFailed: {
target: "ready",
actions: "markHistoryLoadFailed",
},
},
},
ready: {
on: {
ChatSendMessage: {
actions: ["appendGuestUserMessage", "enqueueMessage"],
guard: ({ context, event }) =>
context.canSendMessage && event.content.trim().length > 0,
},
ChatSendImage: {
actions: "appendGuestUserImage",
target: "sending",
},
// 游客不支持翻页历史,发送消息统一走 HTTP 队列。
},
},
sending: {
invoke: {
src: "sendMessageHttp",
input: ({ event }) => ({
content: event.type === "ChatSendMessage" ? event.content : "",
}),
onDone: {
target: "ready",
actions: applyHttpSendOutputAction,
},
onError: {
target: "ready",
actions: markHttpSendFailedAction,
},
},
},
},
},
userSession: {
invoke: [
{
id: "messageQueue",
src: "httpMessageQueue",
},
{
id: "loadHistory",
src: "loadHistory",
},
],
on: {
ChatGuestLogin: {
target: "#chat.guestSession",
actions: "startGuestSession",
},
ChatLogout: {
target: "#chat.idle",
actions: "clearChatSession",
},
ChatNetworkHistoryLoaded: [
{
guard: ({ context, event }) =>
event.type === "ChatNetworkHistoryLoaded" &&
context.paymentUnlockPending &&
shouldPromptUnlockHistory(event.output.messages),
target: ".ready",
actions: "showUnlockHistoryPromptFromNetwork",
},
{
guard: ({ context }) => context.paymentUnlockPending,
target: ".ready",
actions: "applyNetworkHistoryLoadedAndClearPayment",
},
{
guard: ({ context }) =>
!context.isUnlockingHistory && !context.isUnlockingMessage,
actions: "applyNetworkHistoryLoaded",
},
],
ChatHistoryLoadFailed: {
actions: "markHistoryLoadFailed",
},
ChatQueuedSendStarted: {
actions: "markQueuedSendStarted",
},
ChatQueuedHttpDone: {
actions: "applyQueuedHttpOutput",
},
ChatQueuedSendError: {
actions: "appendQueuedSendErrorMessage",
},
ChatPaymentSucceeded: [
{
guard: ({ context }) =>
context.historyLoaded &&
shouldPromptUnlockHistory(context.messages),
actions: ["clearUpgradePrompt", "showUnlockHistoryPrompt"],
},
{
guard: ({ context }) => !context.historyLoaded,
actions: ["clearUpgradePrompt", "markPaymentUnlockPending"],
},
{
guard: ({ context }) => context.historyLoaded,
actions: ["clearUpgradePrompt", "dismissUnlockHistoryPrompt"],
},
],
ChatUnlockHistoryConfirmed: {
target: ".unlockingHistory",
actions: "markUnlockHistoryStarted",
},
ChatUnlockHistoryDismissed: {
actions: "dismissUnlockHistoryPrompt",
},
ChatUnlockPaywallNavigationConsumed: {
actions: "clearUnlockPaywallRequest",
},
},
initial: "initializing",
states: {
initializing: {
on: {
ChatLocalHistoryLoaded: {
target: "ready",
actions: "applyLocalHistoryLoaded",
},
ChatNetworkHistoryLoaded: [
{
guard: ({ context, event }) =>
event.type === "ChatNetworkHistoryLoaded" &&
context.paymentUnlockPending &&
shouldPromptUnlockHistory(event.output.messages),
target: "ready",
actions: "showUnlockHistoryPromptFromNetwork",
},
{
target: "ready",
actions: "applyNetworkHistoryLoadedAndClearPayment",
},
],
ChatHistoryLoadFailed: {
target: "ready",
actions: "markHistoryLoadFailed",
},
},
},
ready: {
on: {
ChatSendMessage: {
guard: ({ context, event }) =>
context.canSendMessage && event.content.trim().length > 0,
actions: ["appendUserMessage", "enqueueMessage"],
},
ChatSendImage: {
actions: "appendUserImage",
},
ChatUnlockMessageRequested: {
guard: ({ event }) => event.messageId.trim().length > 0,
target: "unlockingMessage",
actions: "markUnlockMessageStarted",
},
},
},
sendingViaHttp: {
invoke: {
src: "sendMessageHttp",
input: ({ event }) => ({
content: event.type === "ChatSendMessage" ? event.content : "",
}),
onDone: {
target: "ready",
actions: applyHttpSendOutputAction,
},
onError: {
target: "ready",
actions: markHttpSendFailedAction,
},
},
},
unlockingHistory: {
invoke: {
id: "unlockHistory",
src: "unlockHistory",
onDone: {
target: "ready",
actions: applyUnlockHistoryOutputAction,
},
onError: {
target: "ready",
actions: "markUnlockHistoryFailed",
},
},
},
unlockingMessage: {
invoke: {
id: "unlockMessage",
src: "unlockMessage",
input: ({ context }) => ({
displayMessageId: context.unlockingMessageId ?? "",
...(context.unlockingRemoteMessageId
? { messageId: context.unlockingRemoteMessageId }
: {}),
...(context.unlockingLockType
? { lockType: context.unlockingLockType }
: {}),
...(context.unlockingClientLockId
? { clientLockId: context.unlockingClientLockId }
: {}),
}),
onDone: [
{
guard: ({ event }) => event.output.response.unlocked,
target: "ready",
actions: "applyUnlockMessageSucceeded",
},
{
target: "ready",
actions: "requestUnlockPaymentFromOutput",
},
],
onError: {
target: "ready",
actions: "requestUnlockPaymentFromError",
},
},
},
},
},
},
...chatRootStateConfig,
});
export type ChatMachine = typeof chatMachine;
+104
View File
@@ -0,0 +1,104 @@
import {
applyHistoryLoadedOutput,
applyNetworkHistoryLoadedOutput,
countLockedHistoryMessages,
shouldPromptUnlockHistory,
} from "../chat-machine.helpers";
import { baseChatMachineSetup } from "./setup";
const applyLocalHistoryLoadedAction = baseChatMachineSetup.assign(
({ event }) => {
if (event.type !== "ChatLocalHistoryLoaded") return {};
return applyHistoryLoadedOutput(event.output);
},
);
const applyNetworkHistoryLoadedAction = baseChatMachineSetup.assign(
({ context, event }) => {
if (event.type !== "ChatNetworkHistoryLoaded") return {};
return applyNetworkHistoryLoadedOutput(context, event.output);
},
);
const applyNetworkHistoryLoadedAndClearPaymentAction =
baseChatMachineSetup.assign(({ context, event }) => {
if (event.type !== "ChatNetworkHistoryLoaded") return {};
return {
...applyNetworkHistoryLoadedOutput(context, event.output),
paymentUnlockPending: false,
};
});
const showUnlockHistoryPromptFromNetworkAction = baseChatMachineSetup.assign(
({ context, event }) => {
if (event.type !== "ChatNetworkHistoryLoaded") return {};
const nextHistory = applyNetworkHistoryLoadedOutput(context, event.output);
return {
...nextHistory,
paymentUnlockPending: false,
unlockHistoryPromptVisible: true,
lockedHistoryCount: countLockedHistoryMessages(nextHistory.messages),
unlockHistoryError: null,
};
},
);
const markHistoryLoadFailedAction = baseChatMachineSetup.assign({
historyLoaded: true,
});
export const historyMachineSetup = baseChatMachineSetup.extend({
actions: {
applyLocalHistoryLoaded: applyLocalHistoryLoadedAction,
applyNetworkHistoryLoaded: applyNetworkHistoryLoadedAction,
applyNetworkHistoryLoadedAndClearPayment:
applyNetworkHistoryLoadedAndClearPaymentAction,
showUnlockHistoryPromptFromNetwork:
showUnlockHistoryPromptFromNetworkAction,
markHistoryLoadFailed: markHistoryLoadFailedAction,
},
});
export const guestInitializingState = historyMachineSetup.createStateConfig({
on: {
ChatLocalHistoryLoaded: {
target: "ready",
actions: "applyLocalHistoryLoaded",
},
ChatNetworkHistoryLoaded: {
target: "ready",
actions: "applyNetworkHistoryLoaded",
},
ChatHistoryLoadFailed: {
target: "ready",
actions: "markHistoryLoadFailed",
},
},
});
export const userInitializingState = historyMachineSetup.createStateConfig({
on: {
ChatLocalHistoryLoaded: {
target: "ready",
actions: "applyLocalHistoryLoaded",
},
ChatNetworkHistoryLoaded: [
{
guard: ({ context, event }) =>
event.type === "ChatNetworkHistoryLoaded" &&
context.paymentUnlockPending &&
shouldPromptUnlockHistory(event.output.messages),
target: "ready",
actions: "showUnlockHistoryPromptFromNetwork",
},
{
target: "ready",
actions: "applyNetworkHistoryLoadedAndClearPayment",
},
],
ChatHistoryLoadFailed: {
target: "ready",
actions: "markHistoryLoadFailed",
},
},
});
+233
View File
@@ -0,0 +1,233 @@
import { type DoneActorEvent, type ErrorActorEvent } from "xstate";
import { todayString } from "@/utils/date";
import { Logger } from "@/utils/logger";
import {
applyHttpSendOutput,
beginPendingReply,
finishPendingReply,
type HttpSendOutput,
} from "../chat-machine.helpers";
import { historyMachineSetup } from "./history-flow";
import { createChatActorActionSetup } from "./setup";
const sendLog = new Logger("StoresChatChatSendFlow");
const mediaLog = new Logger("StoresChatChatMediaFlow");
const enqueueMessageAction = historyMachineSetup.sendTo(
"messageQueue",
({ event }) => event,
);
const appendGuestUserMessageAction = historyMachineSetup.assign(
({ context, event }) => {
if (event.type !== "ChatSendMessage") return {};
const today = todayString();
sendLog.debug("[chat-machine] appendGuestUserMessage", {
contentLength: event.content.length,
contentPreview: event.content.slice(0, 50),
quotaMode: "server controlled",
isReplyingAI: context.isReplyingAI,
});
return {
messages: [
...context.messages,
{
content: event.content,
isFromAI: false,
date: today,
},
],
upgradePromptVisible: false,
upgradeReason: null,
};
},
);
const appendUserMessageAction = historyMachineSetup.assign(
({ context, event }) => {
if (event.type !== "ChatSendMessage") return {};
const today = todayString();
sendLog.debug("[chat-machine] appendUserMessage", {
contentLength: event.content.length,
contentPreview: event.content.slice(0, 50),
isReplyingAI: context.isReplyingAI,
});
return {
messages: [
...context.messages,
{
content: event.content,
isFromAI: false,
date: today,
},
],
upgradePromptVisible: false,
upgradeReason: null,
};
},
);
const appendQueuedSendErrorMessageAction = historyMachineSetup.assign(
({ context }) => {
const messages = [
...context.messages,
{
content: "Something went wrong. Try sending again?",
isFromAI: true,
date: todayString(),
},
];
return { messages, ...finishPendingReply(context) };
},
);
const markQueuedSendStartedAction = historyMachineSetup.assign(
({ context }) => beginPendingReply(context),
);
const applyQueuedHttpOutputAction = historyMachineSetup.assign(
({ context, event }) => {
if (event.type !== "ChatQueuedHttpDone") return {};
return applyHttpSendOutput(context, event.output);
},
);
const clearUpgradePromptAction = historyMachineSetup.assign(() => ({
upgradePromptVisible: false,
upgradeReason: null,
canSendMessage: true,
requiredCredits: 0,
shortfallCredits: 0,
}));
const appendGuestUserImageAction = historyMachineSetup.assign(
({ context, event }) => {
if (event.type !== "ChatSendImage") return {};
const today = todayString();
mediaLog.debug("[chat-machine] appendGuestUserImage", {
oldMessagesCount: context.messages.length,
quotaMode: "server controlled",
});
return {
messages: [
...context.messages,
{
content: "[Image]",
isFromAI: false,
date: today,
imageUrl: event.imageBase64,
},
],
...beginPendingReply(context),
upgradePromptVisible: false,
upgradeReason: null,
};
},
);
const appendUserImageAction = historyMachineSetup.assign(
({ context, event }) => {
if (event.type !== "ChatSendImage") return {};
const today = todayString();
mediaLog.debug("[chat-machine] appendUserImage", {
oldMessagesCount: context.messages.length,
});
return {
messages: [
...context.messages,
{
content: "[Image]",
isFromAI: false,
date: today,
imageUrl: event.imageBase64,
},
],
...beginPendingReply(context),
upgradePromptVisible: false,
upgradeReason: null,
};
},
);
export const sendMachineSetup = historyMachineSetup.extend({
actions: {
enqueueMessage: enqueueMessageAction,
appendGuestUserMessage: appendGuestUserMessageAction,
appendUserMessage: appendUserMessageAction,
appendQueuedSendErrorMessage: appendQueuedSendErrorMessageAction,
markQueuedSendStarted: markQueuedSendStartedAction,
applyQueuedHttpOutput: applyQueuedHttpOutputAction,
clearUpgradePrompt: clearUpgradePromptAction,
appendGuestUserImage: appendGuestUserImageAction,
appendUserImage: appendUserImageAction,
},
});
const sendMessageDoneActionSetup =
createChatActorActionSetup<DoneActorEvent<HttpSendOutput>>();
const actorErrorActionSetup = createChatActorActionSetup<ErrorActorEvent>();
const applyHttpSendOutputAction = sendMessageDoneActionSetup.assign(
({ context, event }) => applyHttpSendOutput(context, event.output),
);
const markHttpSendFailedAction = actorErrorActionSetup.assign({
isReplyingAI: false,
});
export const guestReadyState = sendMachineSetup.createStateConfig({
on: {
ChatSendMessage: {
actions: ["appendGuestUserMessage", "enqueueMessage"],
guard: ({ context, event }) =>
context.canSendMessage && event.content.trim().length > 0,
},
ChatSendImage: {
actions: "appendGuestUserImage",
target: "sending",
},
},
});
export const guestSendingState = sendMachineSetup.createStateConfig({
invoke: {
src: "sendMessageHttp",
input: ({ event }) => ({
content: event.type === "ChatSendMessage" ? event.content : "",
}),
onDone: {
target: "ready",
actions: applyHttpSendOutputAction,
},
onError: {
target: "ready",
actions: markHttpSendFailedAction,
},
},
});
export const userSendingViaHttpState = sendMachineSetup.createStateConfig({
invoke: {
src: "sendMessageHttp",
input: ({ event }) => ({
content: event.type === "ChatSendMessage" ? event.content : "",
}),
onDone: {
target: "ready",
actions: applyHttpSendOutputAction,
},
onError: {
target: "ready",
actions: markHttpSendFailedAction,
},
},
});
+232
View File
@@ -0,0 +1,232 @@
import {
createChatPromotionState,
shouldPromptUnlockHistory,
} from "../chat-machine.helpers";
import { initialState } from "../chat-state";
import {
guestInitializingState,
userInitializingState,
} from "./history-flow";
import {
guestReadyState,
guestSendingState,
userSendingViaHttpState,
} from "./send-flow";
import {
unlockingHistoryState,
unlockingMessageState,
unlockMachineSetup,
} from "./unlock-flow";
const startGuestSessionAction = unlockMachineSetup.assign(
({ context }) => ({
...initialState,
promotion: context.promotion,
}),
);
const startUserSessionAction = unlockMachineSetup.assign(
({ context }) => ({
...initialState,
promotion: context.promotion,
}),
);
const clearChatSessionAction = unlockMachineSetup.assign(() => ({
...initialState,
}));
const injectPromotionAction = unlockMachineSetup.assign(({ event }) => {
if (event.type !== "ChatPromotionInjected") return {};
return {
promotion: createChatPromotionState(
event.promotion,
event.messageId,
),
};
});
const clearPromotionAction = unlockMachineSetup.assign({ promotion: null });
export const chatMachineSetup = unlockMachineSetup.extend({
actions: {
startGuestSession: startGuestSessionAction,
startUserSession: startUserSessionAction,
clearChatSession: clearChatSessionAction,
injectPromotion: injectPromotionAction,
clearPromotion: clearPromotionAction,
},
});
const idleState = chatMachineSetup.createStateConfig({
on: {
ChatGuestLogin: {
target: "#chat.guestSession",
actions: "startGuestSession",
},
ChatUserLogin: {
target: "#chat.userSession",
actions: "startUserSession",
},
},
});
const guestSessionState = chatMachineSetup.createStateConfig({
invoke: [
{
id: "messageQueue",
src: "httpMessageQueue",
},
{
id: "loadHistory",
src: "loadHistory",
},
],
on: {
ChatUserLogin: {
target: "#chat.userSession",
actions: "startUserSession",
},
ChatNetworkHistoryLoaded: {
actions: "applyNetworkHistoryLoaded",
},
ChatHistoryLoadFailed: {
actions: "markHistoryLoadFailed",
},
ChatQueuedSendStarted: {
actions: "markQueuedSendStarted",
},
ChatQueuedHttpDone: {
actions: "applyQueuedHttpOutput",
},
ChatQueuedSendError: {
actions: "appendQueuedSendErrorMessage",
},
},
initial: "initializing",
states: {
initializing: guestInitializingState,
ready: guestReadyState,
sending: guestSendingState,
},
});
const userReadyState = chatMachineSetup.createStateConfig({
on: {
ChatSendMessage: {
guard: ({ context, event }) =>
context.canSendMessage && event.content.trim().length > 0,
actions: ["appendUserMessage", "enqueueMessage"],
},
ChatSendImage: {
actions: "appendUserImage",
},
ChatUnlockMessageRequested: {
guard: ({ event }) => event.messageId.trim().length > 0,
target: "unlockingMessage",
actions: "markUnlockMessageStarted",
},
},
});
const userSessionState = chatMachineSetup.createStateConfig({
invoke: [
{
id: "messageQueue",
src: "httpMessageQueue",
},
{
id: "loadHistory",
src: "loadHistory",
},
],
on: {
ChatGuestLogin: {
target: "#chat.guestSession",
actions: "startGuestSession",
},
ChatLogout: {
target: "#chat.idle",
actions: "clearChatSession",
},
ChatNetworkHistoryLoaded: [
{
guard: ({ context, event }) =>
event.type === "ChatNetworkHistoryLoaded" &&
context.paymentUnlockPending &&
shouldPromptUnlockHistory(event.output.messages),
target: ".ready",
actions: "showUnlockHistoryPromptFromNetwork",
},
{
guard: ({ context }) => context.paymentUnlockPending,
target: ".ready",
actions: "applyNetworkHistoryLoadedAndClearPayment",
},
{
guard: ({ context }) =>
!context.isUnlockingHistory && !context.isUnlockingMessage,
actions: "applyNetworkHistoryLoaded",
},
],
ChatHistoryLoadFailed: {
actions: "markHistoryLoadFailed",
},
ChatQueuedSendStarted: {
actions: "markQueuedSendStarted",
},
ChatQueuedHttpDone: {
actions: "applyQueuedHttpOutput",
},
ChatQueuedSendError: {
actions: "appendQueuedSendErrorMessage",
},
ChatPaymentSucceeded: [
{
guard: ({ context }) =>
context.historyLoaded &&
shouldPromptUnlockHistory(context.messages),
actions: ["clearUpgradePrompt", "showUnlockHistoryPrompt"],
},
{
guard: ({ context }) => !context.historyLoaded,
actions: ["clearUpgradePrompt", "markPaymentUnlockPending"],
},
{
guard: ({ context }) => context.historyLoaded,
actions: ["clearUpgradePrompt", "dismissUnlockHistoryPrompt"],
},
],
ChatUnlockHistoryConfirmed: {
target: ".unlockingHistory",
actions: "markUnlockHistoryStarted",
},
ChatUnlockHistoryDismissed: {
actions: "dismissUnlockHistoryPrompt",
},
ChatUnlockPaywallNavigationConsumed: {
actions: "clearUnlockPaywallRequest",
},
},
initial: "initializing",
states: {
initializing: userInitializingState,
ready: userReadyState,
sendingViaHttp: userSendingViaHttpState,
unlockingHistory: unlockingHistoryState,
unlockingMessage: unlockingMessageState,
},
});
export const chatRootStateConfig = chatMachineSetup.createStateConfig({
initial: "idle",
on: {
ChatPromotionInjected: { actions: "injectPromotion" },
ChatPromotionCleared: { actions: "clearPromotion" },
},
states: {
idle: idleState,
guestSession: guestSessionState,
userSession: userSessionState,
},
});
+54
View File
@@ -0,0 +1,54 @@
import { setup, type ActionFunction, type EventObject } from "xstate";
import type { ChatEvent } from "../chat-events";
import {
httpMessageQueueActor,
loadHistoryActor,
sendMessageHttpActor,
unlockHistoryActor,
unlockMessageActor,
} from "../chat-machine.actors";
import type { ChatState } from "../chat-state";
export const baseChatMachineSetup = setup({
types: {
context: {} as ChatState,
events: {} as ChatEvent,
},
actors: {
loadHistory: loadHistoryActor,
sendMessageHttp: sendMessageHttpActor,
httpMessageQueue: httpMessageQueueActor,
unlockHistory: unlockHistoryActor,
unlockMessage: unlockMessageActor,
},
});
type ChatActorAction<TEvent extends EventObject> = ActionFunction<
ChatState,
TEvent,
ChatEvent,
undefined,
never,
never,
never,
never,
never
>;
export function createChatActorActionSetup<TEvent extends EventObject>() {
const actorActionSetup = setup({
types: {
context: {} as ChatState,
events: {} as TEvent,
},
});
return {
assign(
assignment: Parameters<typeof actorActionSetup.assign>[0],
): ChatActorAction<TEvent> {
// Actor lifecycle events are expression events, not public machine events.
return actorActionSetup.assign(assignment) as unknown as ChatActorAction<TEvent>;
},
};
}
+253
View File
@@ -0,0 +1,253 @@
import { type DoneActorEvent } from "xstate";
import {
applyPromotionUnlockOutput,
applySingleUnlockOutput,
countLockedHistoryMessages,
type UnlockMessageOutput,
} from "../chat-machine.helpers";
import type { UnlockHistoryOutput } from "../chat-unlock-flow";
import { sendMachineSetup } from "./send-flow";
import { createChatActorActionSetup } from "./setup";
const markPaymentUnlockPendingAction = sendMachineSetup.assign(() => ({
paymentUnlockPending: true,
unlockHistoryError: null,
}));
const showUnlockHistoryPromptAction = sendMachineSetup.assign(
({ context }) => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: true,
lockedHistoryCount: countLockedHistoryMessages(context.messages),
unlockHistoryError: null,
}),
);
const dismissUnlockHistoryPromptAction = sendMachineSetup.assign(() => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
unlockHistoryError: null,
}));
const markUnlockHistoryStartedAction = sendMachineSetup.assign(() => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
isUnlockingHistory: true,
unlockHistoryError: null,
}));
const markUnlockHistoryFailedAction = sendMachineSetup.assign(() => ({
isUnlockingHistory: false,
unlockHistoryPromptVisible: true,
unlockHistoryError: "Failed to unlock messages. Please try again.",
}));
const markUnlockMessageStartedAction = sendMachineSetup.assign(
({ event }) => {
if (event.type !== "ChatUnlockMessageRequested") return {};
return {
isUnlockingMessage: true,
unlockingMessageId: event.messageId,
unlockingMessageKind: event.kind,
unlockingRemoteMessageId:
event.remoteMessageId ?? (event.lockType ? null : event.messageId),
unlockingLockType: event.lockType ?? null,
unlockingClientLockId: event.clientLockId ?? null,
unlockMessageError: null,
unlockPaywallRequest: null,
};
},
);
const applyUnlockMessageSucceededAction = sendMachineSetup.assign(
({ context, event }) => {
const { output } = event as unknown as DoneActorEvent<UnlockMessageOutput>;
return {
messages: applySingleUnlockOutput(context.messages, output),
promotion: applyPromotionUnlockOutput(context.promotion, output),
isUnlockingMessage: false,
unlockingMessageId: null,
unlockingMessageKind: null,
unlockingRemoteMessageId: null,
unlockingLockType: null,
unlockingClientLockId: null,
unlockMessageError: null,
unlockPaywallRequest: null,
creditBalance: output.response.creditBalance,
creditsCharged: output.response.creditsCharged,
requiredCredits: 0,
shortfallCredits: 0,
};
},
);
const requestUnlockPaymentFromOutputAction = sendMachineSetup.assign(
({ context, event }) => {
const { output } = event as unknown as DoneActorEvent<UnlockMessageOutput>;
return {
promotion: applyPromotionUnlockOutput(context.promotion, output),
isUnlockingMessage: false,
unlockingMessageId: null,
unlockingMessageKind: null,
unlockingRemoteMessageId: null,
unlockingLockType: null,
unlockingClientLockId: null,
unlockMessageError: output.response.reason,
unlockPaywallRequest: {
displayMessageId:
output.response.messageId || output.displayMessageId,
...(output.response.messageId || output.request.messageId
? {
messageId:
output.response.messageId || output.request.messageId,
}
: {}),
kind: context.unlockingMessageKind ?? "private",
...(output.request.lockType
? { lockType: output.request.lockType }
: {}),
...(output.request.clientLockId
? { clientLockId: output.request.clientLockId }
: {}),
...(context.promotion?.message.id === output.displayMessageId
? { promotion: context.promotion.session }
: {}),
reason: output.response.reason,
creditBalance: output.response.creditBalance,
requiredCredits: output.response.requiredCredits,
shortfallCredits: output.response.shortfallCredits,
},
creditBalance: output.response.creditBalance,
requiredCredits: output.response.requiredCredits,
shortfallCredits: output.response.shortfallCredits,
};
},
);
const requestUnlockPaymentFromErrorAction = sendMachineSetup.assign(
({ context }) => ({
isUnlockingMessage: false,
unlockMessageError: "unlock_failed",
unlockPaywallRequest:
context.unlockingMessageId && context.unlockingMessageKind
? {
displayMessageId: context.unlockingMessageId,
...(context.unlockingRemoteMessageId
? { messageId: context.unlockingRemoteMessageId }
: {}),
kind: context.unlockingMessageKind,
...(context.unlockingLockType
? { lockType: context.unlockingLockType }
: {}),
...(context.unlockingClientLockId
? { clientLockId: context.unlockingClientLockId }
: {}),
...(context.promotion?.message.id === context.unlockingMessageId
? { promotion: context.promotion.session }
: {}),
reason: "unlock_failed",
creditBalance: context.creditBalance,
requiredCredits: context.requiredCredits,
shortfallCredits: context.shortfallCredits,
}
: null,
unlockingMessageId: null,
unlockingMessageKind: null,
unlockingRemoteMessageId: null,
unlockingLockType: null,
unlockingClientLockId: null,
}),
);
const clearUnlockPaywallRequestAction = sendMachineSetup.assign(() => ({
unlockPaywallRequest: null,
}));
export const unlockMachineSetup = sendMachineSetup.extend({
actions: {
markPaymentUnlockPending: markPaymentUnlockPendingAction,
showUnlockHistoryPrompt: showUnlockHistoryPromptAction,
dismissUnlockHistoryPrompt: dismissUnlockHistoryPromptAction,
markUnlockHistoryStarted: markUnlockHistoryStartedAction,
markUnlockHistoryFailed: markUnlockHistoryFailedAction,
markUnlockMessageStarted: markUnlockMessageStartedAction,
applyUnlockMessageSucceeded: applyUnlockMessageSucceededAction,
requestUnlockPaymentFromOutput: requestUnlockPaymentFromOutputAction,
requestUnlockPaymentFromError: requestUnlockPaymentFromErrorAction,
clearUnlockPaywallRequest: clearUnlockPaywallRequestAction,
},
});
const unlockHistoryDoneActionSetup =
createChatActorActionSetup<DoneActorEvent<UnlockHistoryOutput>>();
const applyUnlockHistoryOutputAction =
unlockHistoryDoneActionSetup.assign(({ event }) => {
const lockedHistoryCount = countLockedHistoryMessages(
event.output.messages,
);
const insufficientBalance =
!event.output.unlocked &&
event.output.reason === "insufficient_balance";
return {
messages: event.output.messages,
historyLoaded: true,
paymentUnlockPending: false,
unlockHistoryPromptVisible: insufficientBalance,
lockedHistoryCount,
isUnlockingHistory: false,
unlockHistoryError: insufficientBalance
? `Insufficient credits. You still need ${event.output.shortfallCredits} credits.`
: null,
};
});
export const unlockingHistoryState = unlockMachineSetup.createStateConfig({
invoke: {
id: "unlockHistory",
src: "unlockHistory",
onDone: {
target: "ready",
actions: applyUnlockHistoryOutputAction,
},
onError: {
target: "ready",
actions: "markUnlockHistoryFailed",
},
},
});
export const unlockingMessageState = unlockMachineSetup.createStateConfig({
invoke: {
id: "unlockMessage",
src: "unlockMessage",
input: ({ context }) => ({
displayMessageId: context.unlockingMessageId ?? "",
...(context.unlockingRemoteMessageId
? { messageId: context.unlockingRemoteMessageId }
: {}),
...(context.unlockingLockType
? { lockType: context.unlockingLockType }
: {}),
...(context.unlockingClientLockId
? { clientLockId: context.unlockingClientLockId }
: {}),
}),
onDone: [
{
guard: ({ event }) => event.output.response.unlocked,
target: "ready",
actions: "applyUnlockMessageSucceeded",
},
{
target: "ready",
actions: "requestUnlockPaymentFromOutput",
},
],
onError: {
target: "ready",
actions: "requestUnlockPaymentFromError",
},
},
});