refactor(chat): centralize machine actions

This commit is contained in:
2026-07-14 18:35:42 +08:00
parent 5bec04a970
commit a275bb7a2d
8 changed files with 658 additions and 541 deletions
@@ -64,7 +64,9 @@ function makeUnlockPrivateResponse(
function createTestChatMachine(
options: {
historyMessages?: UiMessage[];
sendMessageHttpError?: Error;
unlockHistoryOutput?: UnlockHistoryOutput;
unlockHistoryError?: Error;
unlockMessageOutput?: TestUnlockMessageOutput;
} = {},
) {
@@ -74,10 +76,15 @@ function createTestChatMachine(
sendMessageHttp: fromPromise<
SendMessageHttpOutput,
{ content: string }
>(async () => ({
response: makeChatSendResponse(),
reply: null,
})),
>(async () => {
if (options.sendMessageHttpError) {
throw options.sendMessageHttpError;
}
return {
response: makeChatSendResponse(),
reply: null,
};
}),
httpMessageQueue: fromCallback<ChatEvent>(({ receive, sendBack }) => {
receive((event) => {
if (event.type !== "ChatSendMessage") return;
@@ -92,13 +99,18 @@ function createTestChatMachine(
});
return () => undefined;
}),
unlockHistory: fromPromise<UnlockHistoryOutput>(async () => ({
unlocked: true,
reason: "ok",
shortfallCredits: 0,
messages: [],
...options.unlockHistoryOutput,
})),
unlockHistory: fromPromise<UnlockHistoryOutput>(async () => {
if (options.unlockHistoryError) {
throw options.unlockHistoryError;
}
return {
unlocked: true,
reason: "ok",
shortfallCredits: 0,
messages: [],
...options.unlockHistoryOutput,
};
}),
unlockMessage: fromPromise<
MachineUnlockMessageOutput,
UnlockMessageRequest
@@ -366,6 +378,56 @@ describe("chatMachine transitions", () => {
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: {
@@ -568,6 +630,49 @@ describe("chatMachine transitions", () => {
actor.stop();
});
it("restores the unlock prompt when the history actor fails", async () => {
const actor = createActor(
createTestChatMachine({
historyMessages: [
{
content: "",
isFromAI: true,
date: "2026-06-29",
locked: true,
lockReason: "private_message",
},
{
content: "",
isFromAI: true,
date: "2026-06-29",
locked: true,
lockReason: "voice_message",
},
],
unlockHistoryError: new Error("unlock failed"),
}),
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
actor.send({ type: "ChatPaymentSucceeded" });
actor.send({ type: "ChatUnlockHistoryConfirmed" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
expect(actor.getSnapshot().context).toMatchObject({
isUnlockingHistory: false,
unlockHistoryPromptVisible: true,
unlockHistoryError: "Failed to unlock messages. Please try again.",
});
actor.stop();
});
it("unlocks a single private message without navigating to payment", async () => {
const actor = createActor(
createTestChatMachine({
-13
View File
@@ -1,13 +0,0 @@
import { assign as xAssign } from "xstate";
import type { ChatEvent } from "./chat-events";
import type { ChatState } from "./chat-state";
export type ChatActionArgs = { context: ChatState; event: ChatEvent };
export type ChatContextArgs = { context: ChatState };
export const chatAssign = (
assignment: Parameters<
typeof xAssign<ChatState, ChatEvent, undefined, ChatEvent, never>
>[0],
) => xAssign<ChatState, ChatEvent, undefined, ChatEvent, never>(assignment);
+499
View File
@@ -0,0 +1,499 @@
import {
setup,
type ActionFunction,
type DoneActorEvent,
type ErrorActorEvent,
type EventObject,
} from "xstate";
import { Logger } from "@/utils/logger";
import { todayString } from "@/utils/date";
import type { ChatEvent } from "./chat-events";
import {
applyHistoryLoadedOutput,
applyHttpSendOutput,
applyNetworkHistoryLoadedOutput,
applyPromotionUnlockOutput,
applySingleUnlockOutput,
beginPendingReply,
countLockedHistoryMessages,
createChatPromotionState,
finishPendingReply,
type HttpSendOutput,
type UnlockMessageOutput,
} from "./chat-machine.helpers";
import { baseChatMachineSetup } from "./chat-machine.setup";
import { initialState, type ChatState } from "./chat-state";
import type { UnlockHistoryOutput } from "./chat-unlock-flow";
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>();
export const applyHttpSendOutputAction = sendMessageDoneActionSetup.assign(
({ context, event }) => applyHttpSendOutput(context, event.output),
);
export const markHttpSendFailedAction = actorErrorActionSetup.assign({
isReplyingAI: false,
});
export 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 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,
},
});
+25
View File
@@ -0,0 +1,25 @@
import { setup } 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,
},
});
+13 -194
View File
@@ -1,177 +1,18 @@
/**
* Chat 状态机(XState v5
*
* 登录态流转约束:
* - 未登录:可以进入游客登录 / 其他登录。
* - 游客登录:只可以升级为其他登录,不响应退出。
* - 其他登录:可以退出,不响应游客登录 / 重复其他登录。
*
* 状态结构(parent state 模式):
* - `idle`:屏没挂 / 登出
* - `guestSession`parent):游客会话
* - `userSession`parent):其他登录用户会话
*
* init 任务:
* - guestSession.initializingloadHistory
* - userSession.initializingloadHistory
* - 每个任务都是独立 actor,不互相等待(除非 `always` barrier
*
* 消息发送路径(业务事实):
* - `ChatSendMessage` 只负责即时追加用户消息 + 入队,不进入 sending 子状态
* - guestSession / userSession:队列串行走 HTTP,限制以后端 `lockDetail` 为准
* - `pendingReplyCount` 跟踪待回复数量,`isReplyingAI` 由计数派生
*
* 发送能力:
* - 前端不再处理本地消息额度;游客 / 注册用户均以后端响应为准。
* - 后端返回 `canSendMessage=false` 时展示积分不足充值引导。
*
*/
import { setup, assign, sendTo } from "xstate";
import { ChatState, initialState } from "./chat-state";
import type { ChatEvent } from "./chat-events";
import {
applyHttpSendOutput,
applyHistoryLoadedOutput,
applyNetworkHistoryLoadedOutput,
countLockedHistoryMessages,
shouldPromptUnlockHistory,
createChatPromotionState,
} from "./chat-machine.helpers";
import { loadHistoryActor } from "./chat-history-flow";
import {
appendGuestUserImageAction,
appendUserImageAction,
} from "./chat-media-flow";
import {
appendGuestUserMessageAction,
appendQueuedSendErrorMessageAction,
appendUserMessageAction,
applyQueuedHttpOutputAction,
clearUpgradePromptAction,
httpMessageQueueActor,
markQueuedSendStartedAction,
sendMessageHttpActor,
} from "./chat-send-flow";
import {
applyUnlockMessageSucceededAction,
clearUnlockPaywallRequestAction,
dismissUnlockHistoryPromptAction,
markPaymentUnlockPendingAction,
markUnlockHistoryFailedAction,
markUnlockHistoryStartedAction,
markUnlockMessageStartedAction,
requestUnlockPaymentFromErrorAction,
requestUnlockPaymentFromOutputAction,
showUnlockHistoryPromptAction,
unlockHistoryActor,
unlockMessageActor,
} from "./chat-unlock-flow";
applyHttpSendOutputAction,
applyUnlockHistoryOutputAction,
chatMachineSetup,
markHttpSendFailedAction,
} from "./chat-machine.actions";
import { shouldPromptUnlockHistory } from "./chat-machine.helpers";
import { initialState } from "./chat-state";
// 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变
export type { ChatState } from "./chat-state";
export { initialState } from "./chat-state";
export type { ChatEvent } from "./chat-events";
// ============================================================
// Machine
// ============================================================
export const chatMachine = setup({
types: {
context: {} as ChatState,
events: {} as ChatEvent,
},
actors: {
loadHistory: loadHistoryActor,
sendMessageHttp: sendMessageHttpActor,
httpMessageQueue: httpMessageQueueActor,
unlockHistory: unlockHistoryActor,
unlockMessage: unlockMessageActor,
},
actions: {
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,
enqueueMessage: sendTo("messageQueue", ({ event }) => event),
startGuestSession: assign(({ context }) => ({
...initialState,
promotion: context.promotion,
})),
startUserSession: assign(({ context }) => ({
...initialState,
promotion: context.promotion,
})),
clearChatSession: assign(() => ({
...initialState,
})),
injectPromotion: assign(({ event }) => {
if (event.type !== "ChatPromotionInjected") return {};
return {
promotion: createChatPromotionState(
event.promotion,
event.messageId,
),
};
}),
clearPromotion: assign({ promotion: null }),
applyLocalHistoryLoaded: assign(({ event }) => {
if (event.type !== "ChatLocalHistoryLoaded") return {};
return applyHistoryLoadedOutput(event.output);
}),
applyNetworkHistoryLoaded: assign(({ context, event }) => {
if (event.type !== "ChatNetworkHistoryLoaded") return {};
return applyNetworkHistoryLoadedOutput(context, event.output);
}),
applyNetworkHistoryLoadedAndClearPayment: assign(({ context, event }) => {
if (event.type !== "ChatNetworkHistoryLoaded") return {};
return {
...applyNetworkHistoryLoadedOutput(context, event.output),
paymentUnlockPending: false,
};
}),
showUnlockHistoryPromptFromNetwork: 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,
};
}),
markHistoryLoadFailed: assign({ historyLoaded: true }),
},
}).createMachine({
export const chatMachine = chatMachineSetup.createMachine({
id: "chat",
initial: "idle",
context: initialState,
@@ -265,13 +106,11 @@ export const chatMachine = setup({
}),
onDone: {
target: "ready",
actions: assign(({ context, event }) =>
applyHttpSendOutput(context, event.output),
),
actions: applyHttpSendOutputAction,
},
onError: {
target: "ready",
actions: assign({ isReplyingAI: false }),
actions: markHttpSendFailedAction,
},
},
},
@@ -410,13 +249,11 @@ export const chatMachine = setup({
}),
onDone: {
target: "ready",
actions: assign(({ context, event }) =>
applyHttpSendOutput(context, event.output),
),
actions: applyHttpSendOutputAction,
},
onError: {
target: "ready",
actions: assign({ isReplyingAI: false }),
actions: markHttpSendFailedAction,
},
},
},
@@ -426,25 +263,7 @@ export const chatMachine = setup({
src: "unlockHistory",
onDone: {
target: "ready",
actions: 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,
};
}),
actions: applyUnlockHistoryOutputAction,
},
onError: {
target: "ready",
-59
View File
@@ -1,59 +0,0 @@
import { Logger } from "@/utils/logger";
import { todayString } from "@/utils/date";
import { chatAssign, type ChatActionArgs } from "./chat-flow-actions";
import { beginPendingReply } from "./chat-machine.helpers";
const log = new Logger("StoresChatChatMediaFlow");
export const appendGuestUserImageAction = chatAssign(
({ context, event }: ChatActionArgs) => {
if (event.type !== "ChatSendImage") return {};
const today = todayString();
log.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,
};
},
);
export const appendUserImageAction = chatAssign(
({ context, event }: ChatActionArgs) => {
if (event.type !== "ChatSendImage") return {};
const today = todayString();
log.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,
};
},
);
+1 -99
View File
@@ -7,20 +7,9 @@ import { getChatRepository } from "@/data/repositories/chat_repository";
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result";
import { todayString } from "@/utils/date";
import type { ChatEvent } from "./chat-events";
import {
chatAssign,
type ChatActionArgs,
type ChatContextArgs,
} from "./chat-flow-actions";
import {
applyHttpSendOutput,
beginPendingReply,
finishPendingReply,
sendResponseToUiMessage,
} from "./chat-machine.helpers";
import { sendResponseToUiMessage } from "./chat-machine.helpers";
const log = new Logger("StoresChatChatSendFlow");
@@ -39,93 +28,6 @@ export const httpMessageQueueActor = fromCallback<ChatEvent>(
},
);
export const appendGuestUserMessageAction = chatAssign(
({ context, event }: ChatActionArgs) => {
if (event.type !== "ChatSendMessage") return {};
const today = todayString();
log.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,
};
},
);
export const appendUserMessageAction = chatAssign(
({ context, event }: ChatActionArgs) => {
if (event.type !== "ChatSendMessage") return {};
const today = todayString();
log.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,
};
},
);
export const appendQueuedSendErrorMessageAction = chatAssign(
({ context }: ChatContextArgs) => {
const messages = [
...context.messages,
{
content: "Something went wrong. Try sending again?",
isFromAI: true,
date: todayString(),
},
];
return { messages, ...finishPendingReply(context) };
},
);
export const markQueuedSendStartedAction = chatAssign(
({ context }: ChatContextArgs) =>
beginPendingReply(context),
);
export const applyQueuedHttpOutputAction = chatAssign(
({ context, event }: ChatActionArgs) => {
if (event.type !== "ChatQueuedHttpDone") return {};
return applyHttpSendOutput(context, event.output);
},
);
export const clearUpgradePromptAction = chatAssign(() => ({
upgradePromptVisible: false,
upgradeReason: null,
canSendMessage: true,
requiredCredits: 0,
shortfallCredits: 0,
}));
function createMessageQueueActor(
sendBack: (event: ChatEvent) => void,
receive: (listener: (event: ChatEvent) => void) => void,
+4 -165
View File
@@ -5,16 +5,8 @@ import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identit
import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result";
import {
chatAssign,
type ChatActionArgs,
type ChatContextArgs,
} from "./chat-flow-actions";
import { readAndSyncHistory } from "./chat-history-sync";
import {
applySingleUnlockOutput,
applyPromotionUnlockOutput,
countLockedHistoryMessages,
type UnlockMessageRequest,
type UnlockMessageOutput,
} from "./chat-machine.helpers";
@@ -23,12 +15,14 @@ const log = new Logger("StoresChatChatUnlockFlow");
type UiMessage = import("@/data/dto/chat").UiMessage;
export const unlockHistoryActor = fromPromise<{
export interface UnlockHistoryOutput {
unlocked: boolean;
reason: string;
shortfallCredits: number;
messages: UiMessage[];
}>(async () => {
}
export const unlockHistoryActor = fromPromise<UnlockHistoryOutput>(async () => {
const chatRepo = getChatRepository();
const unlockResult = await chatRepo.unlockHistory();
if (Result.isErr(unlockResult)) {
@@ -100,159 +94,4 @@ export const unlockMessageActor = fromPromise<
};
});
export const markPaymentUnlockPendingAction = chatAssign(() => ({
paymentUnlockPending: true,
unlockHistoryError: null,
}));
export const showUnlockHistoryPromptAction = chatAssign(
({ context }: ChatContextArgs) => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: true,
lockedHistoryCount: countLockedHistoryMessages(context.messages),
unlockHistoryError: null,
}),
);
export const dismissUnlockHistoryPromptAction = chatAssign(() => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
unlockHistoryError: null,
}));
export const markUnlockHistoryStartedAction = chatAssign(() => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
isUnlockingHistory: true,
unlockHistoryError: null,
}));
export const markUnlockHistoryFailedAction = chatAssign(() => ({
isUnlockingHistory: false,
unlockHistoryPromptVisible: true,
unlockHistoryError: "Failed to unlock messages. Please try again.",
}));
export const markUnlockMessageStartedAction = chatAssign(
({ event }: ChatActionArgs) => {
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,
};
},
);
export const applyUnlockMessageSucceededAction = chatAssign(
({ context, event }: ChatActionArgs) => {
const output = (event as { output?: UnlockMessageOutput }).output;
if (!output) return {};
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,
};
},
);
export const requestUnlockPaymentFromOutputAction = chatAssign(
({ context, event }: ChatActionArgs) => {
const output = (event as { output?: UnlockMessageOutput }).output;
if (!output) return {};
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,
};
},
);
export const requestUnlockPaymentFromErrorAction = chatAssign(
({ context }: ChatContextArgs) => ({
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,
}),
);
export const clearUnlockPaywallRequestAction = chatAssign(() => ({
unlockPaywallRequest: null,
}));
export type { UnlockMessageOutput } from "./chat-machine.helpers";