refactor(chat): split machine and repository flows

This commit is contained in:
2026-06-30 19:16:33 +08:00
parent d33c34d751
commit ed109f25a0
12 changed files with 908 additions and 730 deletions
+13
View File
@@ -0,0 +1,13 @@
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);
+51
View File
@@ -0,0 +1,51 @@
import { fromPromise } from "xstate";
import { getChatRepository } from "@/data/repositories/chat_repository";
import { Logger, Result } from "@/utils";
import {
PAGE_SIZE,
localMessagesToUi,
readAndSyncHistory,
} from "./chat-machine.helpers";
const log = new Logger("StoresChatChatHistoryFlow");
type UiMessage = import("@/data/dto/chat").UiMessage;
export const chatHistoryActors = {
loadHistory: fromPromise<{
messages: UiMessage[];
hasMore: boolean;
newOffset: number;
localOverwritten: boolean;
localCount: number;
networkCount: number;
}>(async () => {
return readAndSyncHistory();
}),
loadMoreHistory: fromPromise<
{ messages: UiMessage[]; hasMore: boolean; newOffset: number },
{ offset: number }
>(async ({ input }) => {
const chatRepo = getChatRepository();
const result = await chatRepo.getHistory(PAGE_SIZE, input.offset);
if (Result.isErr(result)) {
log.error("[chat-machine] loadMoreHistoryActor failed", {
error: result.error,
});
throw result.error;
}
const page = localMessagesToUi(result.data.messages);
void chatRepo.prefetchMediaForMessages(result.data.messages);
return {
messages: page,
hasMore: page.length >= PAGE_SIZE,
newOffset: input.offset + page.length,
};
}),
};
export const loadHistoryActor = chatHistoryActors.loadHistory;
export const loadMoreHistoryActor = chatHistoryActors.loadMoreHistory;
+22 -225
View File
@@ -1,227 +1,24 @@
/**
* Chat 状态机:history / send / queue actors
* Backward-compatible actor exports.
*
* Concrete implementations live in capability flow files:
* - chat-history-flow
* - chat-send-flow
* - chat-unlock-flow
*/
import { fromPromise, fromCallback } from "xstate";
import { MessageQueue } from "@/core/net/message-queue";
import type { ChatLockDetailData } from "@/data/schemas/chat";
import type { ChatSendResponse, UnlockPrivateResponse } from "@/data/dto/chat";
import { getChatRepository } from "@/data/repositories/chat_repository";
import { Result, Logger } from "@/utils";
import {
PAGE_SIZE,
localMessagesToUi,
readAndSyncHistory,
sendResponseToUiMessage,
} from "./chat-machine.helpers";
import type { ChatEvent } from "./chat-events";
const log = new Logger("StoresChatChatMachineActors");
// ============================================================
// Init 任务 2:拉 history 3 步流(fromPromise,返回最终结果)
// ============================================================
/**
* local → network → save network to local 3 步流(返回 network 最终 messages
* - 选方案 AfromPromise):UI 不会看到 local 中间态,直接显示 network 结果
* - 内部 3 步走 helper `readAndSyncHistory`(日志全在 helper 里)
* - 失败返 local(退而求其次)
*/
export const loadHistoryActor = fromPromise<{
messages: UiMessage[];
hasMore: boolean;
newOffset: number;
localOverwritten: boolean;
localCount: number;
networkCount: number;
}>(async () => {
return readAndSyncHistory();
});
// ============================================================
// HTTP: one-shot Promise
// ============================================================
/**
* HTTP 发送消息(一次发送 = 一次返回)
* - 后端响应就是 AI 回复(`ChatSendResponse.reply: string`
* - 不再调 `getLocalMessages()`(多此一举)
* - 返回完整 response + 可追加的 reply,方便处理后端 lockDetail 响应
*/
export const sendMessageHttpActor = fromPromise<
{ response: ChatSendResponse; reply: UiMessage | null },
{ content: string }
>(async ({ input }) => {
return sendMessageViaHttp(input.content);
});
/** 翻历史(pagination)—— 不走 local-first,纯 server fetch */
export const loadMoreHistoryActor = fromPromise<
{ messages: UiMessage[]; hasMore: boolean; newOffset: number },
{ offset: number }
>(async ({ input }) => {
const chatRepo = getChatRepository();
const result = await chatRepo.getHistory(PAGE_SIZE, input.offset);
if (Result.isErr(result)) {
log.error("[chat-machine] loadMoreHistoryActor failed", { error: result.error });
throw result.error;
}
const page = localMessagesToUi(result.data.messages);
void chatRepo.prefetchMediaForMessages(result.data.messages);
return {
messages: page,
hasMore: page.length >= PAGE_SIZE,
newOffset: input.offset + page.length,
};
});
export const unlockHistoryActor = fromPromise<{
unlocked: boolean;
reason: string;
shortfallCredits: number;
messages: UiMessage[];
hasMore: boolean;
newOffset: number;
}>(async () => {
const chatRepo = getChatRepository();
const unlockResult = await chatRepo.unlockHistory();
if (Result.isErr(unlockResult)) {
log.error("[chat-machine] unlockHistoryActor failed", {
error: unlockResult.error,
});
throw unlockResult.error;
}
const history = await readAndSyncHistory();
return {
unlocked: unlockResult.data.unlocked,
reason: unlockResult.data.reason,
shortfallCredits: unlockResult.data.shortfallCredits,
messages: history.messages,
hasMore: history.hasMore,
newOffset: history.newOffset,
};
});
export interface UnlockMessageOutput {
messageId: string;
response: UnlockPrivateResponse;
}
export const unlockMessageActor = fromPromise<
UnlockMessageOutput,
{ messageId: string }
>(async ({ input }) => {
const chatRepo = getChatRepository();
const unlockResult = await chatRepo.unlockPrivateMessage(input.messageId);
if (Result.isErr(unlockResult)) {
log.error("[chat-machine] unlockMessageActor failed", {
messageId: input.messageId,
error: unlockResult.error,
});
throw unlockResult.error;
}
if (unlockResult.data.unlocked) {
const markResult = await chatRepo.markPrivateMessageUnlockedInLocal(
input.messageId,
unlockResult.data.content,
unlockResult.data.lockDetail as ChatLockDetailData,
);
if (Result.isErr(markResult)) {
log.warn("[chat-machine] mark unlocked local message failed", {
messageId: input.messageId,
error: markResult.error,
});
}
}
return {
messageId: input.messageId,
response: unlockResult.data,
};
});
export const httpMessageQueueActor = fromCallback<ChatEvent>(
({ sendBack, receive }) => {
return createMessageQueueActor(sendBack, receive);
},
);
function createMessageQueueActor(
sendBack: (event: ChatEvent) => void,
receive: (listener: (event: ChatEvent) => void) => void,
): () => void {
const queue = new MessageQueue();
queue.setConsumer(async (content) => {
sendBack({ type: "ChatQueuedSendStarted" });
try {
const output = await sendMessageViaHttp(content);
sendBack({ type: "ChatQueuedHttpDone", output });
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Message send failed";
log.error("[chat-machine] message queue send failed", {
error,
});
sendBack({
type: "ChatQueuedSendError",
content,
errorMessage,
});
}
});
receive((event) => {
if (event.type !== "ChatSendMessage") return;
const content = event.content.trim();
if (content.length === 0) return;
queue.enqueue(content);
});
return () => queue.dispose();
}
// Re-export `UiMessage` type for the chat-machine.ts public API
type UiMessage = import("@/data/dto/chat").UiMessage;
async function sendMessageViaHttp(content: string): Promise<{
response: ChatSendResponse;
reply: UiMessage | null;
}> {
const chatRepo = getChatRepository();
const result = await chatRepo.sendMessage(content);
if (Result.isErr(result)) {
log.error("[chat-machine] sendMessageHttpActor failed", { error: result.error });
throw result.error;
}
void chatRepo.prefetchMediaForSendResponse(result.data);
const isMessageLimit =
result.data.lockDetail.locked &&
result.data.lockDetail.showUpgrade &&
result.data.lockDetail.reason === "weekly_limit";
const isInsufficientCredits =
result.data.canSendMessage === false &&
result.data.cannotSendReason === "insufficient_credits" &&
!hasRenderableSendResponse(result.data);
return {
response: result.data,
reply:
isMessageLimit || isInsufficientCredits
? null
: sendResponseToUiMessage(result.data),
};
}
function hasRenderableSendResponse(response: ChatSendResponse): boolean {
return (
response.reply.trim().length > 0 ||
response.audioUrl.trim().length > 0 ||
Boolean(response.image.url) ||
response.lockDetail.reason === "private_message" ||
response.lockDetail.reason === "voice_message" ||
response.lockDetail.reason === "image_paywall"
);
}
export {
chatHistoryActors,
loadHistoryActor,
loadMoreHistoryActor,
} from "./chat-history-flow";
export {
chatSendActors,
httpMessageQueueActor,
sendMessageHttpActor,
} from "./chat-send-flow";
export {
chatUnlockActors,
unlockHistoryActor,
unlockMessageActor,
type UnlockMessageOutput,
} from "./chat-unlock-flow";
+6 -2
View File
@@ -1,5 +1,4 @@
import type { UiMessage } from "@/data/dto/chat";
import type { UnlockMessageOutput } from "./chat-machine.actors";
import type { UiMessage, UnlockPrivateResponse } from "@/data/dto/chat";
import { getChatRepository } from "@/data/repositories/chat_repository";
import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
import { todayString, Result, Logger } from "@/utils";
@@ -143,6 +142,11 @@ export type HttpSendOutput = {
reply: UiMessage | null;
};
export interface UnlockMessageOutput {
messageId: string;
response: UnlockPrivateResponse;
}
export function applySingleUnlockOutput(
messages: readonly UiMessage[],
output: UnlockMessageOutput,
+10 -250
View File
@@ -29,31 +29,19 @@
import { setup, assign, sendTo } from "xstate";
import { todayString, Logger } from "@/utils";
import { ChatState, initialState } from "./chat-state";
import type { ChatEvent } from "./chat-events";
import {
applyHttpSendOutput,
applyHistoryLoadedOutput,
applySingleUnlockOutput,
beginPendingReply,
countLockedHistoryMessages,
finishPendingReply,
shouldAutoUnlockHistory,
shouldPromptUnlockHistory,
} from "./chat-machine.helpers";
import {
loadHistoryActor,
sendMessageHttpActor,
loadMoreHistoryActor,
httpMessageQueueActor,
unlockHistoryActor,
unlockMessageActor,
type UnlockMessageOutput,
} from "./chat-machine.actors";
const log = new Logger("StoresChatChatMachine");
import { chatHistoryActors } from "./chat-history-flow";
import { chatMediaActions } from "./chat-media-flow";
import { chatSendActions, chatSendActors } from "./chat-send-flow";
import { chatUnlockActions, chatUnlockActors } from "./chat-unlock-flow";
// 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变
export type { ChatState } from "./chat-state";
@@ -69,14 +57,14 @@ export const chatMachine = setup({
events: {} as ChatEvent,
},
actors: {
loadHistory: loadHistoryActor,
sendMessageHttp: sendMessageHttpActor,
loadMoreHistory: loadMoreHistoryActor,
httpMessageQueue: httpMessageQueueActor,
unlockHistory: unlockHistoryActor,
unlockMessage: unlockMessageActor,
...chatHistoryActors,
...chatSendActors,
...chatUnlockActors,
},
actions: {
...chatSendActions,
...chatMediaActions,
...chatUnlockActions,
enqueueMessage: sendTo("messageQueue", ({ event }) => event),
startGuestSession: assign(() => ({
@@ -90,234 +78,6 @@ export const chatMachine = setup({
clearChatSession: assign(() => ({
...initialState,
})),
appendGuestUserMessage: assign(({ context, event }) => {
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,
};
}),
appendUserMessage: assign(({ context, event }) => {
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,
};
}),
appendGuestUserImage: assign(({ context, event }) => {
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,
};
}),
appendUserImage: assign(({ context, event }) => {
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,
};
}),
appendQueuedSendErrorMessage: assign(({ context }) => {
const messages = [
...context.messages,
{
content: "Something went wrong. Try sending again?",
isFromAI: true,
date: todayString(),
},
];
return { messages, ...finishPendingReply(context) };
}),
markQueuedSendStarted: assign(({ context }) => beginPendingReply(context)),
applyQueuedHttpOutput: assign(({ context, event }) => {
if (event.type !== "ChatQueuedHttpDone") return {};
return applyHttpSendOutput(context, event.output);
}),
clearUpgradePrompt: assign(() => ({
upgradePromptVisible: false,
upgradeReason: null,
canSendMessage: true,
cannotSendReason: null,
requiredCredits: 0,
shortfallCredits: 0,
})),
markPaymentUnlockPending: assign(() => ({
paymentUnlockPending: true,
unlockHistoryError: null,
})),
showUnlockHistoryPrompt: assign(({ context }) => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: true,
lockedHistoryCount: countLockedHistoryMessages(context.messages),
unlockHistoryError: null,
})),
dismissUnlockHistoryPrompt: assign(() => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
unlockHistoryError: null,
})),
markUnlockHistoryStarted: assign(() => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
isUnlockingHistory: true,
unlockHistoryError: null,
})),
markUnlockHistoryFailed: assign(() => ({
isUnlockingHistory: false,
unlockHistoryPromptVisible: true,
unlockHistoryError: "Failed to unlock messages. Please try again.",
})),
markUnlockMessageStarted: assign(({ event }) => {
if (event.type !== "ChatUnlockMessageRequested") return {};
return {
isUnlockingMessage: true,
unlockingMessageId: event.messageId,
unlockingMessageKind: event.kind,
unlockMessageError: null,
unlockPaywallRequest: null,
};
}),
applyUnlockMessageSucceeded: assign(({ context, event }) => {
const output = (event as { output?: UnlockMessageOutput }).output;
if (!output) return {};
return {
messages: applySingleUnlockOutput(context.messages, output),
isUnlockingMessage: false,
unlockingMessageId: null,
unlockingMessageKind: null,
unlockMessageError: null,
unlockPaywallRequest: null,
creditBalance: output.response.creditBalance,
creditsCharged: output.response.creditsCharged,
requiredCredits: 0,
shortfallCredits: 0,
};
}),
requestUnlockPaymentFromOutput: assign(({ context, event }) => {
const output = (event as { output?: UnlockMessageOutput }).output;
if (!output) return {};
return {
isUnlockingMessage: false,
unlockingMessageId: null,
unlockingMessageKind: null,
unlockMessageError: output.response.reason,
unlockPaywallRequest: {
messageId: output.messageId,
kind: context.unlockingMessageKind ?? "private",
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,
};
}),
requestUnlockPaymentFromError: assign(({ context }) => ({
isUnlockingMessage: false,
unlockMessageError: "unlock_failed",
unlockPaywallRequest:
context.unlockingMessageId && context.unlockingMessageKind
? {
messageId: context.unlockingMessageId,
kind: context.unlockingMessageKind,
reason: "unlock_failed",
creditBalance: context.creditBalance,
requiredCredits: context.requiredCredits,
shortfallCredits: context.shortfallCredits,
}
: null,
unlockingMessageId: null,
unlockingMessageKind: null,
})),
clearUnlockPaywallRequest: assign(() => ({
unlockPaywallRequest: null,
})),
},
}).createMachine({
id: "chat",
+56
View File
@@ -0,0 +1,56 @@
import { Logger, todayString } from "@/utils";
import { chatAssign, type ChatActionArgs } from "./chat-flow-actions";
import { beginPendingReply } from "./chat-machine.helpers";
const log = new Logger("StoresChatChatMediaFlow");
export const chatMediaActions = {
appendGuestUserImage: 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,
};
}),
appendUserImage: 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,
};
}),
};
+196
View File
@@ -0,0 +1,196 @@
import { fromCallback, fromPromise } from "xstate";
import { MessageQueue } from "@/core/net/message-queue";
import type { ChatSendResponse } from "@/data/dto/chat";
import { getChatRepository } from "@/data/repositories/chat_repository";
import { Logger, Result, todayString } from "@/utils";
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";
const log = new Logger("StoresChatChatSendFlow");
type UiMessage = import("@/data/dto/chat").UiMessage;
export const chatSendActors = {
sendMessageHttp: fromPromise<
{ response: ChatSendResponse; reply: UiMessage | null },
{ content: string }
>(async ({ input }) => {
return sendMessageViaHttp(input.content);
}),
httpMessageQueue: fromCallback<ChatEvent>(({ sendBack, receive }) => {
return createMessageQueueActor(sendBack, receive);
}),
};
export const chatSendActions = {
appendGuestUserMessage: 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,
};
}),
appendUserMessage: 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,
};
}),
appendQueuedSendErrorMessage: chatAssign(({ context }: ChatContextArgs) => {
const messages = [
...context.messages,
{
content: "Something went wrong. Try sending again?",
isFromAI: true,
date: todayString(),
},
];
return { messages, ...finishPendingReply(context) };
}),
markQueuedSendStarted: chatAssign(({ context }: ChatContextArgs) =>
beginPendingReply(context),
),
applyQueuedHttpOutput: chatAssign(({ context, event }: ChatActionArgs) => {
if (event.type !== "ChatQueuedHttpDone") return {};
return applyHttpSendOutput(context, event.output);
}),
clearUpgradePrompt: chatAssign(() => ({
upgradePromptVisible: false,
upgradeReason: null,
canSendMessage: true,
cannotSendReason: null,
requiredCredits: 0,
shortfallCredits: 0,
})),
};
export const sendMessageHttpActor = chatSendActors.sendMessageHttp;
export const httpMessageQueueActor = chatSendActors.httpMessageQueue;
function createMessageQueueActor(
sendBack: (event: ChatEvent) => void,
receive: (listener: (event: ChatEvent) => void) => void,
): () => void {
const queue = new MessageQueue();
queue.setConsumer(async (content) => {
sendBack({ type: "ChatQueuedSendStarted" });
try {
const output = await sendMessageViaHttp(content);
sendBack({ type: "ChatQueuedHttpDone", output });
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Message send failed";
log.error("[chat-machine] message queue send failed", {
error,
});
sendBack({
type: "ChatQueuedSendError",
content,
errorMessage,
});
}
});
receive((event) => {
if (event.type !== "ChatSendMessage") return;
const content = event.content.trim();
if (content.length === 0) return;
queue.enqueue(content);
});
return () => queue.dispose();
}
async function sendMessageViaHttp(content: string): Promise<{
response: ChatSendResponse;
reply: UiMessage | null;
}> {
const chatRepo = getChatRepository();
const result = await chatRepo.sendMessage(content);
if (Result.isErr(result)) {
log.error("[chat-machine] sendMessageHttpActor failed", {
error: result.error,
});
throw result.error;
}
void chatRepo.prefetchMediaForSendResponse(result.data);
const isMessageLimit =
result.data.lockDetail.locked &&
result.data.lockDetail.showUpgrade &&
result.data.lockDetail.reason === "weekly_limit";
const isInsufficientCredits =
result.data.canSendMessage === false &&
result.data.cannotSendReason === "insufficient_credits" &&
!hasRenderableSendResponse(result.data);
return {
response: result.data,
reply:
isMessageLimit || isInsufficientCredits
? null
: sendResponseToUiMessage(result.data),
};
}
function hasRenderableSendResponse(response: ChatSendResponse): boolean {
return (
response.reply.trim().length > 0 ||
response.audioUrl.trim().length > 0 ||
Boolean(response.image.url) ||
response.lockDetail.reason === "private_message" ||
response.lockDetail.reason === "voice_message" ||
response.lockDetail.reason === "image_paywall"
);
}
+192
View File
@@ -0,0 +1,192 @@
import { fromPromise } from "xstate";
import { getChatRepository } from "@/data/repositories/chat_repository";
import { Logger, Result } from "@/utils";
import {
chatAssign,
type ChatActionArgs,
type ChatContextArgs,
} from "./chat-flow-actions";
import {
applySingleUnlockOutput,
countLockedHistoryMessages,
readAndSyncHistory,
type UnlockMessageOutput,
} from "./chat-machine.helpers";
const log = new Logger("StoresChatChatUnlockFlow");
type UiMessage = import("@/data/dto/chat").UiMessage;
export const chatUnlockActors = {
unlockHistory: fromPromise<{
unlocked: boolean;
reason: string;
shortfallCredits: number;
messages: UiMessage[];
hasMore: boolean;
newOffset: number;
}>(async () => {
const chatRepo = getChatRepository();
const unlockResult = await chatRepo.unlockHistory();
if (Result.isErr(unlockResult)) {
log.error("[chat-machine] unlockHistoryActor failed", {
error: unlockResult.error,
});
throw unlockResult.error;
}
const history = await readAndSyncHistory();
return {
unlocked: unlockResult.data.unlocked,
reason: unlockResult.data.reason,
shortfallCredits: unlockResult.data.shortfallCredits,
messages: history.messages,
hasMore: history.hasMore,
newOffset: history.newOffset,
};
}),
unlockMessage: fromPromise<UnlockMessageOutput, { messageId: string }>(
async ({ input }) => {
const chatRepo = getChatRepository();
const unlockResult = await chatRepo.unlockPrivateMessage(input.messageId);
if (Result.isErr(unlockResult)) {
log.error("[chat-machine] unlockMessageActor failed", {
messageId: input.messageId,
error: unlockResult.error,
});
throw unlockResult.error;
}
if (unlockResult.data.unlocked) {
const markResult = await chatRepo.markPrivateMessageUnlockedInLocal(
input.messageId,
unlockResult.data.content,
unlockResult.data.lockDetail,
);
if (Result.isErr(markResult)) {
log.warn("[chat-machine] mark unlocked local message failed", {
messageId: input.messageId,
error: markResult.error,
});
}
}
return {
messageId: input.messageId,
response: unlockResult.data,
};
},
),
};
export const chatUnlockActions = {
markPaymentUnlockPending: chatAssign(() => ({
paymentUnlockPending: true,
unlockHistoryError: null,
})),
showUnlockHistoryPrompt: chatAssign(({ context }: ChatContextArgs) => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: true,
lockedHistoryCount: countLockedHistoryMessages(context.messages),
unlockHistoryError: null,
})),
dismissUnlockHistoryPrompt: chatAssign(() => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
unlockHistoryError: null,
})),
markUnlockHistoryStarted: chatAssign(() => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
isUnlockingHistory: true,
unlockHistoryError: null,
})),
markUnlockHistoryFailed: chatAssign(() => ({
isUnlockingHistory: false,
unlockHistoryPromptVisible: true,
unlockHistoryError: "Failed to unlock messages. Please try again.",
})),
markUnlockMessageStarted: chatAssign(({ event }: ChatActionArgs) => {
if (event.type !== "ChatUnlockMessageRequested") return {};
return {
isUnlockingMessage: true,
unlockingMessageId: event.messageId,
unlockingMessageKind: event.kind,
unlockMessageError: null,
unlockPaywallRequest: null,
};
}),
applyUnlockMessageSucceeded: chatAssign(({ context, event }: ChatActionArgs) => {
const output = (event as { output?: UnlockMessageOutput }).output;
if (!output) return {};
return {
messages: applySingleUnlockOutput(context.messages, output),
isUnlockingMessage: false,
unlockingMessageId: null,
unlockingMessageKind: null,
unlockMessageError: null,
unlockPaywallRequest: null,
creditBalance: output.response.creditBalance,
creditsCharged: output.response.creditsCharged,
requiredCredits: 0,
shortfallCredits: 0,
};
}),
requestUnlockPaymentFromOutput: chatAssign(({ context, event }: ChatActionArgs) => {
const output = (event as { output?: UnlockMessageOutput }).output;
if (!output) return {};
return {
isUnlockingMessage: false,
unlockingMessageId: null,
unlockingMessageKind: null,
unlockMessageError: output.response.reason,
unlockPaywallRequest: {
messageId: output.messageId,
kind: context.unlockingMessageKind ?? "private",
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,
};
}),
requestUnlockPaymentFromError: chatAssign(({ context }: ChatContextArgs) => ({
isUnlockingMessage: false,
unlockMessageError: "unlock_failed",
unlockPaywallRequest:
context.unlockingMessageId && context.unlockingMessageKind
? {
messageId: context.unlockingMessageId,
kind: context.unlockingMessageKind,
reason: "unlock_failed",
creditBalance: context.creditBalance,
requiredCredits: context.requiredCredits,
shortfallCredits: context.shortfallCredits,
}
: null,
unlockingMessageId: null,
unlockingMessageKind: null,
})),
clearUnlockPaywallRequest: chatAssign(() => ({
unlockPaywallRequest: null,
})),
};
export const unlockHistoryActor = chatUnlockActors.unlockHistory;
export const unlockMessageActor = chatUnlockActors.unlockMessage;
export type { UnlockMessageOutput } from "./chat-machine.helpers";