import { fromCallback, fromPromise } from "xstate"; import { ExceptionHandler } from "@/core/errors"; import { MessageQueue } from "@/core/net/message-queue"; import type { ChatSendResponse } from "@/data/dto/chat"; import { getChatRepository } from "@/data/repositories/chat_repository"; import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity"; 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 sendMessageHttpActor = fromPromise< { response: ChatSendResponse; reply: UiMessage | null }, { content: string } >(async ({ input }) => { return sendMessageViaHttp(input.content); }); export const httpMessageQueueActor = fromCallback( ({ sendBack, receive }) => { return createMessageQueueActor(sendBack, receive); }, ); 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, ): () => 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 = ExceptionHandler.message(error); 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 cacheIdentityResult = await resolveChatCacheOwnerKey(); const result = await chatRepo.sendMessage(content); if (Result.isErr(result)) { log.error("[chat-machine] sendMessageHttpActor failed", { error: result.error, }); throw result.error; } if (Result.isOk(cacheIdentityResult)) { void chatRepo.prefetchMediaForSendResponse( result.data, cacheIdentityResult.data, ); } const isInsufficientCredits = result.data.canSendMessage === false && !hasRenderableSendResponse(result.data); return { response: result.data, reply: isInsufficientCredits ? null : sendResponseToUiMessage(result.data), }; } function hasRenderableSendResponse(response: ChatSendResponse): boolean { const isLockedPaidMessage = response.lockDetail.locked && (response.lockDetail.reason === "private_message" || response.lockDetail.reason === "voice_message" || response.lockDetail.reason === "image_paywall" || response.lockDetail.reason === "image"); return ( response.reply.trim().length > 0 || response.audioUrl.trim().length > 0 || Boolean(response.image.url) || isLockedPaidMessage ); }