refactor(chat): move actors into machine directory

This commit is contained in:
2026-07-15 10:36:22 +08:00
parent 14600641e1
commit 2b90f90ab0
8 changed files with 11 additions and 31 deletions
+44
View File
@@ -0,0 +1,44 @@
import { fromCallback } from "xstate";
import { Logger } from "@/utils/logger";
import {
readLocalHistorySnapshot,
resolveHistoryCacheIdentity,
syncNetworkHistory,
} from "../../chat-history-sync";
import type { ChatEvent } from "../../chat-events";
const log = new Logger("StoresChatChatHistoryFlow");
export const loadHistoryActor = fromCallback<ChatEvent>(({ sendBack }) => {
let cancelled = false;
void (async () => {
const cacheIdentity = await resolveHistoryCacheIdentity();
const localSnapshot = await readLocalHistorySnapshot(cacheIdentity);
if (cancelled) return;
sendBack({
type: "ChatLocalHistoryLoaded",
output: localSnapshot,
});
const networkSnapshot = await syncNetworkHistory(
localSnapshot.localCount,
cacheIdentity,
);
if (cancelled || !networkSnapshot) return;
sendBack({
type: "ChatNetworkHistoryLoaded",
output: networkSnapshot,
});
})().catch((error: unknown) => {
if (cancelled) return;
log.error("[chat-machine] loadHistoryActor failed", { error });
sendBack({ type: "ChatHistoryLoadFailed", error });
});
return () => {
cancelled = true;
};
});
+108
View File
@@ -0,0 +1,108 @@
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 } from "@/utils/logger";
import { Result } from "@/utils/result";
import type { ChatEvent } from "../../chat-events";
import { 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);
},
);
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.lockDetail.locked && response.audioUrl.trim().length > 0) ||
Boolean(response.image.url) ||
isLockedPaidMessage
);
}
+95
View File
@@ -0,0 +1,95 @@
import { fromPromise } from "xstate";
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 { readAndSyncHistory } from "../../chat-history-sync";
import {
type UnlockMessageRequest,
type UnlockMessageOutput,
} from "../../chat-machine.helpers";
const log = new Logger("StoresChatChatUnlockFlow");
type UiMessage = import("@/data/dto/chat").UiMessage;
export interface UnlockHistoryOutput {
unlocked: boolean;
reason: string;
shortfallCredits: number;
messages: UiMessage[];
}
export const unlockHistoryActor = fromPromise<UnlockHistoryOutput>(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,
};
});
export const unlockMessageActor = fromPromise<
UnlockMessageOutput,
UnlockMessageRequest
>(async ({ input }) => {
const chatRepo = getChatRepository();
const cacheIdentityResult = await resolveChatCacheOwnerKey();
const unlockResult = await chatRepo.unlockPrivateMessage({
...(input.messageId ? { messageId: input.messageId } : {}),
...(input.lockType ? { lockType: input.lockType } : {}),
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
});
if (Result.isErr(unlockResult)) {
log.error("[chat-machine] unlockMessageActor failed", {
messageId: input.messageId,
clientLockId: input.clientLockId,
error: unlockResult.error,
});
throw unlockResult.error;
}
if (
unlockResult.data.unlocked &&
input.messageId &&
!input.clientLockId &&
Result.isOk(cacheIdentityResult)
) {
const markResult = await chatRepo.markPrivateMessageUnlockedInLocal(
input.messageId,
{
content: unlockResult.data.content,
audioUrl: unlockResult.data.audioUrl,
...(unlockResult.data.image.url
? { image: unlockResult.data.image }
: {}),
lockDetail: { locked: false, reason: null },
},
cacheIdentityResult.data,
);
if (Result.isErr(markResult)) {
log.warn("[chat-machine] mark unlocked local message failed", {
messageId: input.messageId,
error: markResult.error,
});
}
}
return {
displayMessageId: input.displayMessageId,
request: input,
response: unlockResult.data,
};
});
+4 -2
View File
@@ -1,13 +1,15 @@
import { setup, type ActionFunction, type EventObject } from "xstate";
import type { ChatEvent } from "../chat-events";
import { loadHistoryActor } from "./actors/history";
import {
httpMessageQueueActor,
loadHistoryActor,
sendMessageHttpActor,
} from "./actors/send";
import {
unlockHistoryActor,
unlockMessageActor,
} from "../chat-machine.actors";
} from "./actors/unlock";
import type { ChatState } from "../chat-state";
export const baseChatMachineSetup = setup({
+1 -1
View File
@@ -6,7 +6,7 @@ import {
countLockedHistoryMessages,
type UnlockMessageOutput,
} from "../chat-machine.helpers";
import type { UnlockHistoryOutput } from "../chat-unlock-flow";
import type { UnlockHistoryOutput } from "./actors/unlock";
import { sendMachineSetup } from "./send-flow";
import { createChatActorActionSetup } from "./setup";