refactor(chat): split machine and repository flows
This commit is contained in:
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
* - 选方案 A(fromPromise):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";
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user