diff --git a/src/stores/chat/__tests__/chat-machine.transitions.test.ts b/src/stores/chat/__tests__/chat-machine.transitions.test.ts index 689e6c7c..e4143f99 100644 --- a/src/stores/chat/__tests__/chat-machine.transitions.test.ts +++ b/src/stores/chat/__tests__/chat-machine.transitions.test.ts @@ -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(({ receive, sendBack }) => { receive((event) => { if (event.type !== "ChatSendMessage") return; @@ -92,13 +99,18 @@ function createTestChatMachine( }); return () => undefined; }), - unlockHistory: fromPromise(async () => ({ - unlocked: true, - reason: "ok", - shortfallCredits: 0, - messages: [], - ...options.unlockHistoryOutput, - })), + unlockHistory: fromPromise(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({ diff --git a/src/stores/chat/chat-flow-actions.ts b/src/stores/chat/chat-flow-actions.ts deleted file mode 100644 index e83aef44..00000000 --- a/src/stores/chat/chat-flow-actions.ts +++ /dev/null @@ -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 - >[0], -) => xAssign(assignment); diff --git a/src/stores/chat/chat-machine.actions.ts b/src/stores/chat/chat-machine.actions.ts new file mode 100644 index 00000000..5bd63bc2 --- /dev/null +++ b/src/stores/chat/chat-machine.actions.ts @@ -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; + 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; + 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 = ActionFunction< + ChatState, + TEvent, + ChatEvent, + undefined, + never, + never, + never, + never, + never +>; + +function createChatActorActionSetup() { + const actorActionSetup = setup({ + types: { + context: {} as ChatState, + events: {} as TEvent, + }, + }); + return { + assign( + assignment: Parameters[0], + ): ChatActorAction { + // Actor lifecycle events are expression events, not public machine events. + return actorActionSetup.assign(assignment) as unknown as ChatActorAction; + }, + }; +} + +const sendMessageDoneActionSetup = + createChatActorActionSetup>(); +const unlockHistoryDoneActionSetup = + createChatActorActionSetup>(); +const actorErrorActionSetup = createChatActorActionSetup(); + +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, + }, +}); diff --git a/src/stores/chat/chat-machine.setup.ts b/src/stores/chat/chat-machine.setup.ts new file mode 100644 index 00000000..dff3e018 --- /dev/null +++ b/src/stores/chat/chat-machine.setup.ts @@ -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, + }, +}); diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index d4047bbe..65498b39 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -1,177 +1,18 @@ -/** - * Chat 状态机(XState v5) - * - * 登录态流转约束: - * - 未登录:可以进入游客登录 / 其他登录。 - * - 游客登录:只可以升级为其他登录,不响应退出。 - * - 其他登录:可以退出,不响应游客登录 / 重复其他登录。 - * - * 状态结构(parent state 模式): - * - `idle`:屏没挂 / 登出 - * - `guestSession`(parent):游客会话 - * - `userSession`(parent):其他登录用户会话 - * - * init 任务: - * - guestSession.initializing:loadHistory - * - userSession.initializing:loadHistory - * - 每个任务都是独立 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", diff --git a/src/stores/chat/chat-media-flow.ts b/src/stores/chat/chat-media-flow.ts deleted file mode 100644 index aea8fcef..00000000 --- a/src/stores/chat/chat-media-flow.ts +++ /dev/null @@ -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, - }; - }, -); diff --git a/src/stores/chat/chat-send-flow.ts b/src/stores/chat/chat-send-flow.ts index 7f2f6bb3..45af2b0f 100644 --- a/src/stores/chat/chat-send-flow.ts +++ b/src/stores/chat/chat-send-flow.ts @@ -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( }, ); -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, diff --git a/src/stores/chat/chat-unlock-flow.ts b/src/stores/chat/chat-unlock-flow.ts index ad6d7863..6d2b9317 100644 --- a/src/stores/chat/chat-unlock-flow.ts +++ b/src/stores/chat/chat-unlock-flow.ts @@ -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(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";