b126b83c4c
Scope message and media caches to the active user or guest identity. Preserve identity snapshots across async chat flows and discard legacy unscoped message data during the IndexedDB upgrade.
205 lines
5.5 KiB
TypeScript
205 lines
5.5 KiB
TypeScript
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<ChatEvent>(
|
|
({ 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
|
|
);
|
|
}
|