refactor(chat): split machine by business flow
This commit is contained in:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+7
-483
@@ -1,490 +1,14 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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 {
|
import {
|
||||||
ChatSendResponse,
|
createTestChatMachine,
|
||||||
UnlockPrivateResponse,
|
makeUnlockPrivateResponse,
|
||||||
type UiMessage,
|
} from "./chat-machine.test-utils";
|
||||||
} 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();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
describe("chat unlock flow", () => {
|
||||||
it("prompts before unlocking multiple locked history messages after payment", async () => {
|
it("prompts before unlocking multiple locked history messages after payment", async () => {
|
||||||
const actor = createActor(
|
const actor = createActor(
|
||||||
createTestChatMachine({
|
createTestChatMachine({
|
||||||
@@ -1,824 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
setup,
|
chatMachineSetup,
|
||||||
type ActionFunction,
|
chatRootStateConfig,
|
||||||
type DoneActorEvent,
|
} from "./machine/session-flow";
|
||||||
type ErrorActorEvent,
|
import { initialState } from "./chat-state";
|
||||||
type EventObject,
|
|
||||||
} from "xstate";
|
|
||||||
|
|
||||||
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 type { ChatState } from "./chat-state";
|
||||||
export { initialState } from "./chat-state";
|
export { initialState } from "./chat-state";
|
||||||
export type { ChatEvent } from "./chat-events";
|
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({
|
export const chatMachine = chatMachineSetup.createMachine({
|
||||||
id: "chat",
|
id: "chat",
|
||||||
initial: "idle",
|
|
||||||
context: initialState,
|
context: initialState,
|
||||||
on: {
|
...chatRootStateConfig,
|
||||||
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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ChatMachine = typeof chatMachine;
|
export type ChatMachine = typeof chatMachine;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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>;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user