feat(characters): support character-scoped conversations
This commit is contained in:
@@ -22,6 +22,10 @@ export interface LoadMoreHistoryActorEvent {
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface ChatHistoryActorInput {
|
||||
characterId: string;
|
||||
}
|
||||
|
||||
export interface LoadMoreHistoryOutput {
|
||||
messages: UiMessage[];
|
||||
offset: number;
|
||||
@@ -29,11 +33,14 @@ export interface LoadMoreHistoryOutput {
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export const loadHistoryActor = fromCallback<ChatEvent>(({ sendBack }) => {
|
||||
export const loadHistoryActor = fromCallback<
|
||||
ChatEvent,
|
||||
ChatHistoryActorInput
|
||||
>(({ input, sendBack }) => {
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
const cacheIdentity = await resolveHistoryCacheIdentity();
|
||||
const cacheIdentity = await resolveHistoryCacheIdentity(input.characterId);
|
||||
const localSnapshot = await readLocalHistorySnapshot(cacheIdentity);
|
||||
if (cancelled) return;
|
||||
sendBack({
|
||||
@@ -42,6 +49,7 @@ export const loadHistoryActor = fromCallback<ChatEvent>(({ sendBack }) => {
|
||||
});
|
||||
|
||||
const networkSnapshot = await syncNetworkHistory(
|
||||
input.characterId,
|
||||
localSnapshot.localCount,
|
||||
cacheIdentity,
|
||||
);
|
||||
@@ -62,52 +70,58 @@ export const loadHistoryActor = fromCallback<ChatEvent>(({ sendBack }) => {
|
||||
});
|
||||
|
||||
export const loadMoreHistoryActor =
|
||||
fromCallback<LoadMoreHistoryActorEvent>(({ receive, sendBack }) => {
|
||||
let cancelled = false;
|
||||
let running = false;
|
||||
fromCallback<LoadMoreHistoryActorEvent, ChatHistoryActorInput>(
|
||||
({ input, receive, sendBack }) => {
|
||||
let cancelled = false;
|
||||
let running = false;
|
||||
|
||||
receive((event) => {
|
||||
if (running || cancelled) return;
|
||||
running = true;
|
||||
receive((event) => {
|
||||
if (running || cancelled) return;
|
||||
running = true;
|
||||
|
||||
void (async () => {
|
||||
const chatRepo = await loadChatRepository();
|
||||
const historyResult = await chatRepo.getHistory(
|
||||
event.limit,
|
||||
event.offset,
|
||||
);
|
||||
if (Result.isErr(historyResult)) throw historyResult.error;
|
||||
if (cancelled) return;
|
||||
|
||||
const response = historyResult.data;
|
||||
sendBack({
|
||||
type: "ChatOlderHistoryLoaded",
|
||||
output: {
|
||||
messages: localMessagesToUi(response.messages),
|
||||
offset: event.offset,
|
||||
total: response.total,
|
||||
limit: normalizeHistoryLimit(response.limit, event.limit),
|
||||
} satisfies LoadMoreHistoryOutput,
|
||||
});
|
||||
void resolveHistoryCacheIdentity().then((cacheIdentity) => {
|
||||
if (!cacheIdentity) return;
|
||||
void chatRepo.prefetchMediaForMessages(
|
||||
response.messages,
|
||||
cacheIdentity,
|
||||
void (async () => {
|
||||
const chatRepo = await loadChatRepository();
|
||||
const historyResult = await chatRepo.getHistory(
|
||||
input.characterId,
|
||||
event.limit,
|
||||
event.offset,
|
||||
);
|
||||
});
|
||||
})()
|
||||
.catch((error: unknown) => {
|
||||
if (Result.isErr(historyResult)) throw historyResult.error;
|
||||
if (cancelled) return;
|
||||
log.warn("[chat-machine] loadMoreHistoryActor failed", { error });
|
||||
sendBack({ type: "ChatOlderHistoryLoadFailed", error });
|
||||
})
|
||||
.finally(() => {
|
||||
running = false;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
const response = historyResult.data;
|
||||
sendBack({
|
||||
type: "ChatOlderHistoryLoaded",
|
||||
output: {
|
||||
messages: localMessagesToUi(response.messages),
|
||||
offset: event.offset,
|
||||
total: response.total,
|
||||
limit: normalizeHistoryLimit(response.limit, event.limit),
|
||||
} satisfies LoadMoreHistoryOutput,
|
||||
});
|
||||
void resolveHistoryCacheIdentity(input.characterId).then(
|
||||
(cacheIdentity) => {
|
||||
if (!cacheIdentity) return;
|
||||
void chatRepo.prefetchMediaForMessages(
|
||||
response.messages,
|
||||
input.characterId,
|
||||
cacheIdentity,
|
||||
);
|
||||
},
|
||||
);
|
||||
})()
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
log.warn("[chat-machine] loadMoreHistoryActor failed", { error });
|
||||
sendBack({ type: "ChatOlderHistoryLoadFailed", error });
|
||||
})
|
||||
.finally(() => {
|
||||
running = false;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ExceptionHandler } from "@/core/errors";
|
||||
import { MessageQueue } from "@/core/net/message-queue";
|
||||
import type { ChatSendResponse } from "@/data/dto/chat";
|
||||
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
||||
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
|
||||
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
@@ -17,18 +17,22 @@ type UiMessage = import("@/data/dto/chat").UiMessage;
|
||||
|
||||
export const sendMessageHttpActor = fromPromise<
|
||||
{ response: ChatSendResponse; reply: UiMessage | null },
|
||||
{ content: string }
|
||||
{ characterId: string; content: string }
|
||||
>(async ({ input }) => {
|
||||
return sendMessageViaHttp(input.content);
|
||||
return sendMessageViaHttp(input.characterId, input.content);
|
||||
});
|
||||
|
||||
export const httpMessageQueueActor = fromCallback<ChatEvent>(
|
||||
({ sendBack, receive }) => {
|
||||
return createMessageQueueActor(sendBack, receive);
|
||||
export const httpMessageQueueActor = fromCallback<
|
||||
ChatEvent,
|
||||
{ characterId: string }
|
||||
>(
|
||||
({ input, sendBack, receive }) => {
|
||||
return createMessageQueueActor(input.characterId, sendBack, receive);
|
||||
},
|
||||
);
|
||||
|
||||
function createMessageQueueActor(
|
||||
characterId: string,
|
||||
sendBack: (event: ChatEvent) => void,
|
||||
receive: (listener: (event: ChatEvent) => void) => void,
|
||||
): () => void {
|
||||
@@ -37,7 +41,7 @@ function createMessageQueueActor(
|
||||
queue.setConsumer(async (content) => {
|
||||
sendBack({ type: "ChatQueuedSendStarted" });
|
||||
try {
|
||||
const output = await sendMessageViaHttp(content);
|
||||
const output = await sendMessageViaHttp(characterId, content);
|
||||
sendBack({ type: "ChatQueuedHttpDone", output });
|
||||
} catch (error) {
|
||||
const errorMessage = ExceptionHandler.message(error);
|
||||
@@ -62,13 +66,13 @@ function createMessageQueueActor(
|
||||
return () => queue.dispose();
|
||||
}
|
||||
|
||||
async function sendMessageViaHttp(content: string): Promise<{
|
||||
async function sendMessageViaHttp(characterId: string, content: string): Promise<{
|
||||
response: ChatSendResponse;
|
||||
reply: UiMessage | null;
|
||||
}> {
|
||||
const chatRepo = await loadChatRepository();
|
||||
const cacheIdentityResult = await resolveChatCacheOwnerKey();
|
||||
const result = await chatRepo.sendMessage(content);
|
||||
const cacheIdentityResult = await resolveChatConversationKey(characterId);
|
||||
const result = await chatRepo.sendMessage(characterId, content);
|
||||
if (Result.isErr(result)) {
|
||||
log.error("[chat-machine] sendMessageHttpActor failed", {
|
||||
error: result.error,
|
||||
@@ -78,6 +82,7 @@ async function sendMessageViaHttp(content: string): Promise<{
|
||||
if (Result.isOk(cacheIdentityResult)) {
|
||||
void chatRepo.prefetchMediaForSendResponse(
|
||||
result.data,
|
||||
characterId,
|
||||
cacheIdentityResult.data,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fromPromise } from "xstate";
|
||||
|
||||
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
||||
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
|
||||
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
@@ -22,9 +22,12 @@ export interface UnlockHistoryOutput {
|
||||
messages: UiMessage[];
|
||||
}
|
||||
|
||||
export const unlockHistoryActor = fromPromise<UnlockHistoryOutput>(async () => {
|
||||
export const unlockHistoryActor = fromPromise<
|
||||
UnlockHistoryOutput,
|
||||
{ characterId: string }
|
||||
>(async ({ input }) => {
|
||||
const chatRepo = await loadChatRepository();
|
||||
const unlockResult = await chatRepo.unlockHistory();
|
||||
const unlockResult = await chatRepo.unlockHistory(input.characterId);
|
||||
if (Result.isErr(unlockResult)) {
|
||||
log.error("[chat-machine] unlockHistoryActor failed", {
|
||||
error: unlockResult.error,
|
||||
@@ -32,7 +35,7 @@ export const unlockHistoryActor = fromPromise<UnlockHistoryOutput>(async () => {
|
||||
throw unlockResult.error;
|
||||
}
|
||||
|
||||
const history = await readAndSyncHistory();
|
||||
const history = await readAndSyncHistory(input.characterId);
|
||||
return {
|
||||
unlocked: unlockResult.data.unlocked,
|
||||
reason: unlockResult.data.reason,
|
||||
@@ -43,11 +46,14 @@ export const unlockHistoryActor = fromPromise<UnlockHistoryOutput>(async () => {
|
||||
|
||||
export const unlockMessageActor = fromPromise<
|
||||
UnlockMessageOutput,
|
||||
UnlockMessageRequest
|
||||
UnlockMessageRequest & { characterId: string }
|
||||
>(async ({ input }) => {
|
||||
const chatRepo = await loadChatRepository();
|
||||
const cacheIdentityResult = await resolveChatCacheOwnerKey();
|
||||
const cacheIdentityResult = await resolveChatConversationKey(
|
||||
input.characterId,
|
||||
);
|
||||
const unlockResult = await chatRepo.unlockPrivateMessage({
|
||||
characterId: input.characterId,
|
||||
...(input.messageId ? { messageId: input.messageId } : {}),
|
||||
...(input.lockType ? { lockType: input.lockType } : {}),
|
||||
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
|
||||
|
||||
@@ -205,7 +205,8 @@ export const guestReadyState = sendMachineSetup.createStateConfig({
|
||||
export const guestSendingState = sendMachineSetup.createStateConfig({
|
||||
invoke: {
|
||||
src: "sendMessageHttp",
|
||||
input: ({ event }) => ({
|
||||
input: ({ context, event }) => ({
|
||||
characterId: context.characterId,
|
||||
content: event.type === "ChatSendMessage" ? event.content : "",
|
||||
}),
|
||||
onDone: {
|
||||
@@ -222,7 +223,8 @@ export const guestSendingState = sendMachineSetup.createStateConfig({
|
||||
export const userSendingViaHttpState = sendMachineSetup.createStateConfig({
|
||||
invoke: {
|
||||
src: "sendMessageHttp",
|
||||
input: ({ event }) => ({
|
||||
input: ({ context, event }) => ({
|
||||
characterId: context.characterId,
|
||||
content: event.type === "ChatSendMessage" ? event.content : "",
|
||||
}),
|
||||
onDone: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createChatPromotionState } from "../helper/promotion";
|
||||
import { shouldPromptUnlockHistory } from "../helper/unlock";
|
||||
import { initialState } from "../chat-state";
|
||||
import { createInitialChatState } from "../chat-state";
|
||||
import {
|
||||
guestInitializingState,
|
||||
historyPaginationTransitions,
|
||||
@@ -19,20 +19,20 @@ import {
|
||||
|
||||
const startGuestSessionAction = unlockMachineSetup.assign(
|
||||
({ context }) => ({
|
||||
...initialState,
|
||||
...createInitialChatState(context.characterId),
|
||||
promotion: context.promotion,
|
||||
}),
|
||||
);
|
||||
|
||||
const startUserSessionAction = unlockMachineSetup.assign(
|
||||
({ context }) => ({
|
||||
...initialState,
|
||||
...createInitialChatState(context.characterId),
|
||||
promotion: context.promotion,
|
||||
}),
|
||||
);
|
||||
|
||||
const clearChatSessionAction = unlockMachineSetup.assign(() => ({
|
||||
...initialState,
|
||||
const clearChatSessionAction = unlockMachineSetup.assign(({ context }) => ({
|
||||
...createInitialChatState(context.characterId),
|
||||
}));
|
||||
|
||||
const injectPromotionAction = unlockMachineSetup.assign(({ event }) => {
|
||||
@@ -75,14 +75,17 @@ const guestSessionState = chatMachineSetup.createStateConfig({
|
||||
{
|
||||
id: "messageQueue",
|
||||
src: "httpMessageQueue",
|
||||
input: ({ context }) => ({ characterId: context.characterId }),
|
||||
},
|
||||
{
|
||||
id: "loadHistory",
|
||||
src: "loadHistory",
|
||||
input: ({ context }) => ({ characterId: context.characterId }),
|
||||
},
|
||||
{
|
||||
id: "loadMoreHistory",
|
||||
src: "loadMoreHistory",
|
||||
input: ({ context }) => ({ characterId: context.characterId }),
|
||||
},
|
||||
],
|
||||
on: {
|
||||
@@ -154,14 +157,17 @@ const userSessionState = chatMachineSetup.createStateConfig({
|
||||
{
|
||||
id: "messageQueue",
|
||||
src: "httpMessageQueue",
|
||||
input: ({ context }) => ({ characterId: context.characterId }),
|
||||
},
|
||||
{
|
||||
id: "loadHistory",
|
||||
src: "loadHistory",
|
||||
input: ({ context }) => ({ characterId: context.characterId }),
|
||||
},
|
||||
{
|
||||
id: "loadMoreHistory",
|
||||
src: "loadMoreHistory",
|
||||
input: ({ context }) => ({ characterId: context.characterId }),
|
||||
},
|
||||
],
|
||||
on: {
|
||||
|
||||
@@ -15,10 +15,15 @@ import {
|
||||
} from "./actors/unlock";
|
||||
import type { ChatState } from "../chat-state";
|
||||
|
||||
export interface ChatMachineInput {
|
||||
characterId?: string;
|
||||
}
|
||||
|
||||
export const baseChatMachineSetup = setup({
|
||||
types: {
|
||||
context: {} as ChatState,
|
||||
events: {} as ChatEvent,
|
||||
input: {} as ChatMachineInput | undefined,
|
||||
},
|
||||
actors: {
|
||||
loadHistory: loadHistoryActor,
|
||||
|
||||
@@ -188,6 +188,7 @@ export const unlockingHistoryState = unlockMachineSetup.createStateConfig({
|
||||
invoke: {
|
||||
id: "unlockHistory",
|
||||
src: "unlockHistory",
|
||||
input: ({ context }) => ({ characterId: context.characterId }),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: applyUnlockHistoryOutputAction,
|
||||
@@ -203,11 +204,13 @@ export const unlockingMessageState = unlockMachineSetup.createStateConfig({
|
||||
invoke: {
|
||||
id: "unlockMessage",
|
||||
src: "unlockMessage",
|
||||
input: ({ context }) =>
|
||||
context.unlockingMessage ?? {
|
||||
input: ({ context }) => ({
|
||||
characterId: context.characterId,
|
||||
...(context.unlockingMessage ?? {
|
||||
displayMessageId: "",
|
||||
kind: "private",
|
||||
},
|
||||
kind: "private" as const,
|
||||
}),
|
||||
}),
|
||||
onDone: [
|
||||
{
|
||||
guard: ({ event }) => event.output.response.unlocked,
|
||||
|
||||
Reference in New Issue
Block a user