feat(characters): support character-scoped conversations

This commit is contained in:
2026-07-17 11:42:31 +08:00
parent 93efcb6604
commit 2796010971
85 changed files with 1645 additions and 251 deletions
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { ChatSendResponse } from "@/data/dto/chat";
import type { ChatState } from "@/stores/chat/chat-state";
import {
@@ -29,6 +30,7 @@ function makeResponse(
function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
return {
characterId: DEFAULT_CHARACTER_ID,
messages: [],
promotion: null,
outgoingMessageRevision: 0,
@@ -9,11 +9,14 @@ import type { LoadMoreHistoryActorEvent } from "@/stores/chat/machine/actors/his
import {
createLoadHistoryCallback,
createTestChatMachine,
TEST_CHAT_MACHINE_INPUT,
} from "./chat-machine.test-utils";
describe("chat history flow", () => {
it("enters user ready after history is loaded", async () => {
const actor = createActor(createTestChatMachine()).start();
const actor = createActor(createTestChatMachine(), {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
@@ -45,7 +48,10 @@ describe("chat history flow", () => {
};
const machine = chatMachine.provide({
actors: {
loadHistory: fromCallback<ChatEvent>(({ sendBack }) => {
loadHistory: fromCallback<
ChatEvent,
{ characterId: string }
>(({ sendBack }) => {
sendBack({
type: "ChatLocalHistoryLoaded",
output: {
@@ -70,7 +76,9 @@ describe("chat history flow", () => {
}),
},
});
const actor = createActor(machine).start();
const actor = createActor(machine, {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
@@ -109,7 +117,10 @@ describe("chat history flow", () => {
[latestMessage],
{ total: 120, limit: 50 },
),
loadMoreHistory: fromCallback<LoadMoreHistoryActorEvent>(
loadMoreHistory: fromCallback<
LoadMoreHistoryActorEvent,
{ characterId: string }
>(
({ receive, sendBack }) => {
receive((event) => {
requests.push(event);
@@ -143,7 +154,9 @@ describe("chat history flow", () => {
),
},
});
const actor = createActor(machine).start();
const actor = createActor(machine, {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
@@ -183,7 +196,10 @@ describe("chat history flow", () => {
total: 75,
limit: 50,
}),
loadMoreHistory: fromCallback<LoadMoreHistoryActorEvent>(
loadMoreHistory: fromCallback<
LoadMoreHistoryActorEvent,
{ characterId: string }
>(
({ receive, sendBack }) => {
receive((event) => {
requestCount += 1;
@@ -209,7 +225,9 @@ describe("chat history flow", () => {
),
},
});
const actor = createActor(machine).start();
const actor = createActor(machine, {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatGuestLogin" });
await waitFor(actor, (snapshot) =>
@@ -240,7 +258,10 @@ describe("chat history flow", () => {
total: 50,
limit: 50,
}),
loadMoreHistory: fromCallback<LoadMoreHistoryActorEvent>(
loadMoreHistory: fromCallback<
LoadMoreHistoryActorEvent,
{ characterId: string }
>(
({ receive }) => {
receive(() => {
requested = true;
@@ -250,7 +271,9 @@ describe("chat history flow", () => {
),
},
});
const actor = createActor(machine).start();
const actor = createActor(machine, {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
@@ -5,12 +5,14 @@ import {
UnlockPrivateResponse,
type UiMessage,
} from "@/data/dto/chat";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { chatMachine } from "@/stores/chat/chat-machine";
import type { ChatEvent } from "@/stores/chat/chat-events";
import type {
UnlockMessageOutput as MachineUnlockMessageOutput,
UnlockMessageRequest,
} from "@/stores/chat/helper/unlock";
import type { ChatHistoryActorInput } from "@/stores/chat/machine/actors/history";
export interface SendMessageHttpOutput {
response: ChatSendResponse;
@@ -29,6 +31,10 @@ export interface TestUnlockMessageOutput {
response: UnlockPrivateResponse;
}
export const TEST_CHAT_MACHINE_INPUT = {
characterId: DEFAULT_CHARACTER_ID,
};
export function makeChatSendResponse(): ChatSendResponse {
return ChatSendResponse.from({
reply: "",
@@ -74,7 +80,7 @@ export function createTestChatMachine(
loadHistory: createLoadHistoryCallback(options.historyMessages ?? []),
sendMessageHttp: fromPromise<
SendMessageHttpOutput,
{ content: string }
{ characterId: string; content: string }
>(async () => {
if (options.sendMessageHttpError) {
throw options.sendMessageHttpError;
@@ -84,21 +90,26 @@ export function createTestChatMachine(
reply: null,
};
}),
httpMessageQueue: fromCallback<ChatEvent>(({ receive, sendBack }) => {
receive((event) => {
if (event.type !== "ChatSendMessage") return;
sendBack({ type: "ChatQueuedSendStarted" });
sendBack({
type: "ChatQueuedHttpDone",
output: {
response: makeChatSendResponse(),
reply: null,
},
httpMessageQueue: fromCallback<ChatEvent, { characterId: string }>(
({ receive, sendBack }) => {
receive((event) => {
if (event.type !== "ChatSendMessage") return;
sendBack({ type: "ChatQueuedSendStarted" });
sendBack({
type: "ChatQueuedHttpDone",
output: {
response: makeChatSendResponse(),
reply: null,
},
});
});
});
return () => undefined;
}),
unlockHistory: fromPromise<UnlockHistoryOutput>(async () => {
return () => undefined;
},
),
unlockHistory: fromPromise<
UnlockHistoryOutput,
{ characterId: string }
>(async () => {
if (options.unlockHistoryError) {
throw options.unlockHistoryError;
}
@@ -112,7 +123,7 @@ export function createTestChatMachine(
}),
unlockMessage: fromPromise<
MachineUnlockMessageOutput,
UnlockMessageRequest
UnlockMessageRequest & { characterId: string }
>(async ({ input }) => {
const output = options.unlockMessageOutput ?? {
messageId: input.messageId ?? input.displayMessageId,
@@ -136,7 +147,7 @@ export function createLoadHistoryCallback(
limit: 50,
},
) {
return fromCallback<ChatEvent>(({ sendBack }) => {
return fromCallback<ChatEvent, ChatHistoryActorInput>(({ sendBack }) => {
sendBack({
type: "ChatLocalHistoryLoaded",
output: {
@@ -8,13 +8,16 @@ import {
createLoadHistoryCallback,
createTestChatMachine,
makeChatSendResponse,
TEST_CHAT_MACHINE_INPUT,
type SendMessageHttpOutput,
type UnlockHistoryOutput,
} from "./chat-machine.test-utils";
describe("chat send flow", () => {
it("allows multiple messages to be queued without leaving ready state", async () => {
const actor = createActor(createTestChatMachine()).start();
const actor = createActor(createTestChatMachine(), {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
@@ -44,7 +47,9 @@ describe("chat send flow", () => {
});
it("applies direct HTTP actor output with a type-bound action", async () => {
const actor = createActor(createTestChatMachine()).start();
const actor = createActor(createTestChatMachine(), {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatGuestLogin" });
await waitFor(actor, (snapshot) =>
@@ -70,7 +75,9 @@ describe("chat send flow", () => {
});
it("increments the outgoing revision for a user image", async () => {
const actor = createActor(createTestChatMachine()).start();
const actor = createActor(createTestChatMachine(), {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
@@ -95,6 +102,7 @@ describe("chat send flow", () => {
createTestChatMachine({
sendMessageHttpError: new Error("send failed"),
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatGuestLogin" });
@@ -121,29 +129,34 @@ describe("chat send flow", () => {
loadHistory: createLoadHistoryCallback([]),
sendMessageHttp: fromPromise<
SendMessageHttpOutput,
{ content: string }
{ characterId: string; content: string }
>(async () => ({
response: makeChatSendResponse(),
reply: null,
})),
httpMessageQueue: fromCallback<ChatEvent>(({ receive, sendBack }) => {
const pending: string[] = [];
receive((event) => {
if (event.type !== "ChatSendMessage") return;
pending.push(event.content);
if (pending.length !== 2) return;
sendBack({ type: "ChatQueuedSendStarted" });
sendBack({
type: "ChatQueuedHttpDone",
output: {
response: makeChatSendResponse(),
reply: null,
},
httpMessageQueue: fromCallback<ChatEvent, { characterId: string }>(
({ receive, sendBack }) => {
const pending: string[] = [];
receive((event) => {
if (event.type !== "ChatSendMessage") return;
pending.push(event.content);
if (pending.length !== 2) return;
sendBack({ type: "ChatQueuedSendStarted" });
sendBack({
type: "ChatQueuedHttpDone",
output: {
response: makeChatSendResponse(),
reply: null,
},
});
});
});
return () => undefined;
}),
unlockHistory: fromPromise<UnlockHistoryOutput>(async () => ({
return () => undefined;
},
),
unlockHistory: fromPromise<
UnlockHistoryOutput,
{ characterId: string }
>(async () => ({
unlocked: true,
reason: "ok",
shortfallCredits: 0,
@@ -151,7 +164,9 @@ describe("chat send flow", () => {
})),
},
});
const actor = createActor(machine).start();
const actor = createActor(machine, {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
@@ -1,11 +1,25 @@
import { describe, expect, it } from "vitest";
import { createActor, waitFor } from "xstate";
import { createTestChatMachine } from "./chat-machine.test-utils";
import {
createTestChatMachine,
TEST_CHAT_MACHINE_INPUT,
} from "./chat-machine.test-utils";
describe("chat session flow", () => {
it("initializes the conversation from the supplied character", () => {
const actor = createActor(createTestChatMachine(), {
input: { characterId: "character_aria" },
}).start();
expect(actor.getSnapshot().context.characterId).toBe("character_aria");
actor.stop();
});
it("keeps guest logout disabled and supports user to guest transition", async () => {
const actor = createActor(createTestChatMachine()).start();
const actor = createActor(createTestChatMachine(), {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatGuestLogin" });
await waitFor(actor, (snapshot) =>
@@ -29,7 +43,9 @@ describe("chat session flow", () => {
});
it("allows explicit logout from user session", async () => {
const actor = createActor(createTestChatMachine()).start();
const actor = createActor(createTestChatMachine(), {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
@@ -54,6 +70,7 @@ describe("chat session flow", () => {
},
],
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
@@ -82,7 +99,9 @@ describe("chat session flow", () => {
});
it("switches to guest session when guest login arrives during user session", async () => {
const actor = createActor(createTestChatMachine()).start();
const actor = createActor(createTestChatMachine(), {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
@@ -98,7 +117,9 @@ describe("chat session flow", () => {
});
it("ignores repeated user login while an authenticated user session is active", async () => {
const actor = createActor(createTestChatMachine()).start();
const actor = createActor(createTestChatMachine(), {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
@@ -10,6 +10,7 @@ import type {
import {
createTestChatMachine,
makeUnlockPrivateResponse,
TEST_CHAT_MACHINE_INPUT,
} from "./chat-machine.test-utils";
describe("chat unlock flow", () => {
@@ -33,6 +34,7 @@ describe("chat unlock flow", () => {
},
],
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
@@ -80,6 +82,7 @@ describe("chat unlock flow", () => {
],
},
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
@@ -137,6 +140,7 @@ describe("chat unlock flow", () => {
],
},
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
@@ -179,6 +183,7 @@ describe("chat unlock flow", () => {
],
unlockHistoryError: new Error("unlock failed"),
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
@@ -222,6 +227,7 @@ describe("chat unlock flow", () => {
}),
},
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
@@ -283,6 +289,7 @@ describe("chat unlock flow", () => {
}),
},
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
@@ -342,6 +349,7 @@ describe("chat unlock flow", () => {
}),
},
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
@@ -395,6 +403,7 @@ describe("chat unlock flow", () => {
}),
},
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
@@ -453,6 +462,7 @@ describe("chat unlock flow", () => {
}),
},
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
@@ -520,6 +530,7 @@ describe("chat unlock flow", () => {
}),
},
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
@@ -575,14 +586,16 @@ describe("chat unlock flow", () => {
actors: {
unlockMessage: fromPromise<
UnlockMessageOutput,
UnlockMessageRequest
UnlockMessageRequest & { characterId: string }
>(async ({ input }) => {
capturedRequest = input;
return unlockDeferred;
}),
},
});
const actor = createActor(machine).start();
const actor = createActor(machine, {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
@@ -641,7 +654,9 @@ describe("chat unlock flow", () => {
});
it("clears the insufficient credits prompt after payment succeeds", async () => {
const actor = createActor(createTestChatMachine()).start();
const actor = createActor(createTestChatMachine(), {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
+14 -2
View File
@@ -4,6 +4,8 @@ import { type Dispatch, type ReactNode, useMemo } from "react";
import { createActorContext, shallowEqual } from "@xstate/react";
import type { SnapshotFrom } from "xstate";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { chatMachine } from "./chat-machine";
import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
import { appendPromotionMessage } from "./helper/promotion";
@@ -15,6 +17,7 @@ import { appendPromotionMessage } from "./helper/promotion";
* 消费方(chat-screen)通过 `useAuthState()` 取 loginStatus,本地派生 isGuest。
*/
interface ChatState {
characterId: string;
messages: MachineContext["messages"];
historyMessages: MachineContext["messages"];
promotion: MachineContext["promotion"];
@@ -43,10 +46,18 @@ const ChatActorContext = createActorContext(chatMachine);
export interface ChatProviderProps {
children: ReactNode;
characterId?: string;
}
export function ChatProvider({ children }: ChatProviderProps) {
return <ChatActorContext.Provider>{children}</ChatActorContext.Provider>;
export function ChatProvider({
children,
characterId = DEFAULT_CHARACTER_ID,
}: ChatProviderProps) {
return (
<ChatActorContext.Provider options={{ input: { characterId } }}>
{children}
</ChatActorContext.Provider>
);
}
export function useChatState(): ChatState {
@@ -73,6 +84,7 @@ type SelectedChatState = Omit<ChatState, "messages">;
function selectChatState(state: ChatSnapshot): SelectedChatState {
return {
characterId: state.context.characterId,
historyMessages: state.context.messages,
promotion: state.context.promotion,
outgoingMessageRevision: state.context.outgoingMessageRevision,
+17 -6
View File
@@ -1,5 +1,5 @@
import type { UiMessage } from "@/data/dto/chat";
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result";
@@ -41,8 +41,10 @@ export function createGreetingMessage(): UiMessage {
};
}
export async function resolveHistoryCacheIdentity(): Promise<string | null> {
const result = await resolveChatCacheOwnerKey();
export async function resolveHistoryCacheIdentity(
characterId: string,
): Promise<string | null> {
const result = await resolveChatConversationKey(characterId);
return Result.isOk(result) ? result.data : null;
}
@@ -76,13 +78,18 @@ export async function readLocalHistorySnapshot(
}
export async function syncNetworkHistory(
characterId: string,
localCount: number,
cacheIdentity: string | null,
): Promise<NetworkHistorySyncOutput | null> {
const chatRepo = await loadChatRepository();
const greetingMessage = createGreetingMessage();
const networkResult = await chatRepo.getHistory(CHAT_HISTORY_LIMIT, 0);
const networkResult = await chatRepo.getHistory(
characterId,
CHAT_HISTORY_LIMIT,
0,
);
if (Result.isErr(networkResult)) {
log.error("[chat-machine] loadHistory NETWORK FAILED", {
error: networkResult.error,
@@ -97,6 +104,7 @@ export async function syncNetworkHistory(
if (cacheIdentity) {
void chatRepo.prefetchMediaForMessages(
networkResult.data.messages,
characterId,
cacheIdentity,
);
}
@@ -134,10 +142,13 @@ export async function syncNetworkHistory(
* 2. Read network history as the authoritative source.
* 3. Overwrite local history with network data.
*/
export async function readAndSyncHistory(): Promise<ReadAndSyncHistoryOutput> {
const cacheIdentity = await resolveHistoryCacheIdentity();
export async function readAndSyncHistory(
characterId: string,
): Promise<ReadAndSyncHistoryOutput> {
const cacheIdentity = await resolveHistoryCacheIdentity(characterId);
const localSnapshot = await readLocalHistorySnapshot(cacheIdentity);
const networkSnapshot = await syncNetworkHistory(
characterId,
localSnapshot.localCount,
cacheIdentity,
);
+5 -2
View File
@@ -2,7 +2,9 @@ import {
chatMachineSetup,
chatRootStateConfig,
} from "./machine/session-flow";
import { initialState } from "./chat-state";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { createInitialChatState } from "./chat-state";
export type { ChatState } from "./chat-state";
export { initialState } from "./chat-state";
@@ -10,7 +12,8 @@ export type { ChatEvent } from "./chat-events";
export const chatMachine = chatMachineSetup.createMachine({
id: "chat",
context: initialState,
context: ({ input }) =>
createInitialChatState(input?.characterId ?? DEFAULT_CHARACTER_ID),
...chatRootStateConfig,
});
+35 -26
View File
@@ -2,6 +2,7 @@ import type { UiMessage } from "@/data/dto/chat";
import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session";
import type { ChatLockType } from "@/data/schemas/chat";
import type { PendingChatPromotion } from "@/data/storage/navigation";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import type { ChatPromotionState } from "./helper/promotion";
export type ChatUpgradeReason = "insufficient_credits";
@@ -24,6 +25,7 @@ export interface ChatUnlockPaywallRequest
}
export interface ChatState {
characterId: string;
messages: UiMessage[];
promotion: ChatPromotionState | null;
outgoingMessageRevision: number;
@@ -51,29 +53,36 @@ export interface ChatState {
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
}
export const initialState: ChatState = {
messages: [],
promotion: null,
outgoingMessageRevision: 0,
isReplyingAI: false,
pendingReplyCount: 0,
upgradePromptVisible: false,
upgradeReason: null,
canSendMessage: true,
creditBalance: 0,
creditsCharged: 0,
requiredCredits: 0,
shortfallCredits: 0,
historyLoaded: false,
historyTotal: 0,
historyLimit: 50,
nextHistoryOffset: 0,
isLoadingMoreHistory: false,
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
lockedHistoryCount: 0,
unlockHistoryError: null,
unlockingMessage: null,
unlockMessageError: null,
unlockPaywallRequest: null,
};
export function createInitialChatState(
characterId: string = DEFAULT_CHARACTER_ID,
): ChatState {
return {
characterId,
messages: [],
promotion: null,
outgoingMessageRevision: 0,
isReplyingAI: false,
pendingReplyCount: 0,
upgradePromptVisible: false,
upgradeReason: null,
canSendMessage: true,
creditBalance: 0,
creditsCharged: 0,
requiredCredits: 0,
shortfallCredits: 0,
historyLoaded: false,
historyTotal: 0,
historyLimit: 50,
nextHistoryOffset: 0,
isLoadingMoreHistory: false,
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
lockedHistoryCount: 0,
unlockHistoryError: null,
unlockingMessage: null,
unlockMessageError: null,
unlockPaywallRequest: null,
};
}
export const initialState: ChatState = createInitialChatState();
+60 -46
View File
@@ -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;
};
},
);
+15 -10
View File
@@ -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,
);
}
+12 -6
View File
@@ -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 } : {}),
+4 -2
View File
@@ -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: {
+11 -5
View File
@@ -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: {
+5
View File
@@ -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,
+7 -4
View File
@@ -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,