refactor(characters): drive shared UI from active character
This commit is contained in:
@@ -62,15 +62,18 @@ describe("chat actor request cancellation", () => {
|
||||
() => new Promise(() => undefined),
|
||||
);
|
||||
const actor = createActor(loadHistoryActor, {
|
||||
input: { characterId: "character_elio" },
|
||||
input: {
|
||||
characterId: "character_elio",
|
||||
emptyChatGreeting: "Hello from Elio",
|
||||
},
|
||||
}).start();
|
||||
await vi.waitFor(() =>
|
||||
expect(historySync.syncNetworkHistory).toHaveBeenCalled(),
|
||||
);
|
||||
|
||||
const signal = getSignal(
|
||||
historySync.syncNetworkHistory.mock.calls[0]?.[3],
|
||||
);
|
||||
const historyCall = historySync.syncNetworkHistory.mock.calls[0];
|
||||
expect(historyCall?.[3]).toBe("Hello from Elio");
|
||||
const signal = getSignal(historyCall?.[4]);
|
||||
expect(signal.aborted).toBe(false);
|
||||
actor.stop();
|
||||
expect(signal.aborted).toBe(true);
|
||||
@@ -81,7 +84,10 @@ describe("chat actor request cancellation", () => {
|
||||
() => new Promise(() => undefined),
|
||||
);
|
||||
const actor = createActor(unlockHistoryActor, {
|
||||
input: { characterId: "character_elio" },
|
||||
input: {
|
||||
characterId: "character_elio",
|
||||
emptyChatGreeting: "Hello from Elio",
|
||||
},
|
||||
}).start();
|
||||
await vi.waitFor(() => expect(repository.unlockHistory).toHaveBeenCalled());
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
import {
|
||||
DEFAULT_CHARACTER,
|
||||
DEFAULT_CHARACTER_ID,
|
||||
} from "@/data/constants/character";
|
||||
import {
|
||||
ChatSendResponseSchema,
|
||||
type ChatSendResponse,
|
||||
@@ -35,6 +38,7 @@ function makeResponse(
|
||||
function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
||||
return {
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
emptyChatGreeting: DEFAULT_CHARACTER.emptyChatGreeting,
|
||||
messages: [],
|
||||
promotion: null,
|
||||
outgoingMessageRevision: 0,
|
||||
|
||||
@@ -4,7 +4,10 @@ import { createActor, fromCallback, waitFor } from "xstate";
|
||||
import type { UiMessage } from "@/stores/chat/ui-message";
|
||||
import type { ChatEvent } from "@/stores/chat/chat-events";
|
||||
import { chatMachine } from "@/stores/chat/chat-machine";
|
||||
import type { LoadMoreHistoryActorEvent } from "@/stores/chat/machine/actors/history";
|
||||
import type {
|
||||
ChatHistoryActorInput,
|
||||
LoadMoreHistoryActorEvent,
|
||||
} from "@/stores/chat/machine/actors/history";
|
||||
|
||||
import {
|
||||
createLoadHistoryCallback,
|
||||
@@ -50,7 +53,7 @@ describe("chat history flow", () => {
|
||||
actors: {
|
||||
loadHistory: fromCallback<
|
||||
ChatEvent,
|
||||
{ characterId: string }
|
||||
ChatHistoryActorInput
|
||||
>(({ sendBack }) => {
|
||||
sendBack({
|
||||
type: "ChatLocalHistoryLoaded",
|
||||
@@ -119,7 +122,7 @@ describe("chat history flow", () => {
|
||||
),
|
||||
loadMoreHistory: fromCallback<
|
||||
LoadMoreHistoryActorEvent,
|
||||
{ characterId: string }
|
||||
ChatHistoryActorInput
|
||||
>(
|
||||
({ receive, sendBack }) => {
|
||||
receive((event) => {
|
||||
@@ -198,7 +201,7 @@ describe("chat history flow", () => {
|
||||
}),
|
||||
loadMoreHistory: fromCallback<
|
||||
LoadMoreHistoryActorEvent,
|
||||
{ characterId: string }
|
||||
ChatHistoryActorInput
|
||||
>(
|
||||
({ receive, sendBack }) => {
|
||||
receive((event) => {
|
||||
@@ -260,7 +263,7 @@ describe("chat history flow", () => {
|
||||
}),
|
||||
loadMoreHistory: fromCallback<
|
||||
LoadMoreHistoryActorEvent,
|
||||
{ characterId: string }
|
||||
ChatHistoryActorInput
|
||||
>(
|
||||
({ receive }) => {
|
||||
receive(() => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { fromCallback, fromPromise } from "xstate";
|
||||
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
import {
|
||||
DEFAULT_CHARACTER,
|
||||
DEFAULT_CHARACTER_ID,
|
||||
} from "@/data/constants/character";
|
||||
import {
|
||||
ChatSendResponseSchema,
|
||||
UnlockPrivateResponseSchema,
|
||||
@@ -36,6 +39,7 @@ export interface TestUnlockMessageOutput {
|
||||
|
||||
export const TEST_CHAT_MACHINE_INPUT = {
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
emptyChatGreeting: DEFAULT_CHARACTER.emptyChatGreeting,
|
||||
};
|
||||
|
||||
export function makeChatSendResponse(): ChatSendResponse {
|
||||
@@ -109,7 +113,10 @@ export function createTestChatMachine(
|
||||
return () => undefined;
|
||||
},
|
||||
),
|
||||
unlockHistory: fromPromise<UnlockHistoryOutput, { characterId: string }>(
|
||||
unlockHistory: fromPromise<
|
||||
UnlockHistoryOutput,
|
||||
{ characterId: string; emptyChatGreeting: string }
|
||||
>(
|
||||
async () => {
|
||||
if (options.unlockHistoryError) {
|
||||
throw options.unlockHistoryError;
|
||||
|
||||
@@ -155,7 +155,7 @@ describe("chat send flow", () => {
|
||||
),
|
||||
unlockHistory: fromPromise<
|
||||
UnlockHistoryOutput,
|
||||
{ characterId: string }
|
||||
{ characterId: string; emptyChatGreeting: string }
|
||||
>(async () => ({
|
||||
unlocked: true,
|
||||
reason: "ok",
|
||||
|
||||
@@ -9,10 +9,43 @@ import {
|
||||
describe("chat session flow", () => {
|
||||
it("initializes the conversation from the supplied character", () => {
|
||||
const actor = createActor(createTestChatMachine(), {
|
||||
input: { characterId: "character_aria" },
|
||||
input: {
|
||||
characterId: "character_maya",
|
||||
emptyChatGreeting: "Hello from Maya",
|
||||
},
|
||||
}).start();
|
||||
|
||||
expect(actor.getSnapshot().context.characterId).toBe("character_aria");
|
||||
expect(actor.getSnapshot().context).toMatchObject({
|
||||
characterId: "character_maya",
|
||||
emptyChatGreeting: "Hello from Maya",
|
||||
});
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("keeps the active character greeting across login state changes", async () => {
|
||||
const actor = createActor(createTestChatMachine(), {
|
||||
input: {
|
||||
characterId: "character_maya",
|
||||
emptyChatGreeting: "Hello from Maya",
|
||||
},
|
||||
}).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
expect(actor.getSnapshot().context.emptyChatGreeting).toBe(
|
||||
"Hello from Maya",
|
||||
);
|
||||
|
||||
actor.send({ type: "ChatGuestLogin" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ guestSession: "ready" }),
|
||||
);
|
||||
expect(actor.getSnapshot().context.emptyChatGreeting).toBe(
|
||||
"Hello from Maya",
|
||||
);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ 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";
|
||||
@@ -46,15 +44,19 @@ const ChatActorContext = createActorContext(chatMachine);
|
||||
|
||||
export interface ChatProviderProps {
|
||||
children: ReactNode;
|
||||
characterId?: string;
|
||||
characterId: string;
|
||||
emptyChatGreeting: string;
|
||||
}
|
||||
|
||||
export function ChatProvider({
|
||||
children,
|
||||
characterId = DEFAULT_CHARACTER_ID,
|
||||
characterId,
|
||||
emptyChatGreeting,
|
||||
}: ChatProviderProps) {
|
||||
return (
|
||||
<ChatActorContext.Provider options={{ input: { characterId } }}>
|
||||
<ChatActorContext.Provider
|
||||
options={{ input: { characterId, emptyChatGreeting } }}
|
||||
>
|
||||
{children}
|
||||
</ChatActorContext.Provider>
|
||||
);
|
||||
|
||||
@@ -30,12 +30,9 @@ export type LocalHistorySnapshotOutput = {
|
||||
|
||||
export type NetworkHistorySyncOutput = ReadAndSyncHistoryOutput;
|
||||
|
||||
export function createGreetingMessage(): UiMessage {
|
||||
export function createGreetingMessage(content: string): UiMessage {
|
||||
return {
|
||||
content:
|
||||
"You're here! Facebook is so restrictive, " +
|
||||
"there were things I couldn't say there. " +
|
||||
"Finally I can relax. How was your day out?",
|
||||
content,
|
||||
isFromAI: true,
|
||||
date: todayString(),
|
||||
isSynthetic: true,
|
||||
@@ -51,9 +48,10 @@ export async function resolveHistoryCacheIdentity(
|
||||
|
||||
export async function readLocalHistorySnapshot(
|
||||
cacheIdentity: string | null,
|
||||
emptyChatGreeting: string,
|
||||
): Promise<LocalHistorySnapshotOutput> {
|
||||
const chatRepo = await loadChatRepository();
|
||||
const greetingMessage = createGreetingMessage();
|
||||
const greetingMessage = createGreetingMessage(emptyChatGreeting);
|
||||
|
||||
const localResult = cacheIdentity
|
||||
? await chatRepo.getLocalMessages(cacheIdentity)
|
||||
@@ -82,10 +80,11 @@ export async function syncNetworkHistory(
|
||||
characterId: string,
|
||||
localCount: number,
|
||||
cacheIdentity: string | null,
|
||||
emptyChatGreeting: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NetworkHistorySyncOutput | null> {
|
||||
const chatRepo = await loadChatRepository();
|
||||
const greetingMessage = createGreetingMessage();
|
||||
const greetingMessage = createGreetingMessage(emptyChatGreeting);
|
||||
|
||||
const networkResult = await chatRepo.getHistory(
|
||||
characterId,
|
||||
@@ -149,14 +148,19 @@ export async function syncNetworkHistory(
|
||||
*/
|
||||
export async function readAndSyncHistory(
|
||||
characterId: string,
|
||||
emptyChatGreeting: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ReadAndSyncHistoryOutput> {
|
||||
const cacheIdentity = await resolveHistoryCacheIdentity(characterId);
|
||||
const localSnapshot = await readLocalHistorySnapshot(cacheIdentity);
|
||||
const localSnapshot = await readLocalHistorySnapshot(
|
||||
cacheIdentity,
|
||||
emptyChatGreeting,
|
||||
);
|
||||
const networkSnapshot = await syncNetworkHistory(
|
||||
characterId,
|
||||
localSnapshot.localCount,
|
||||
cacheIdentity,
|
||||
emptyChatGreeting,
|
||||
signal,
|
||||
);
|
||||
if (networkSnapshot) return networkSnapshot;
|
||||
|
||||
@@ -2,7 +2,10 @@ import {
|
||||
chatMachineSetup,
|
||||
chatRootStateConfig,
|
||||
} from "./machine/session-flow";
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
import {
|
||||
DEFAULT_CHARACTER,
|
||||
DEFAULT_CHARACTER_ID,
|
||||
} from "@/data/constants/character";
|
||||
|
||||
import { createInitialChatState } from "./chat-state";
|
||||
|
||||
@@ -13,7 +16,10 @@ export type { ChatEvent } from "./chat-events";
|
||||
export const chatMachine = chatMachineSetup.createMachine({
|
||||
id: "chat",
|
||||
context: ({ input }) =>
|
||||
createInitialChatState(input?.characterId ?? DEFAULT_CHARACTER_ID),
|
||||
createInitialChatState(
|
||||
input?.characterId ?? DEFAULT_CHARACTER_ID,
|
||||
input?.emptyChatGreeting ?? DEFAULT_CHARACTER.emptyChatGreeting,
|
||||
),
|
||||
...chatRootStateConfig,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { UiMessage } from "@/stores/chat/ui-message";
|
||||
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 {
|
||||
DEFAULT_CHARACTER,
|
||||
DEFAULT_CHARACTER_ID,
|
||||
} from "@/data/constants/character";
|
||||
import type { ChatPromotionState } from "./helper/promotion";
|
||||
|
||||
export type ChatUpgradeReason = "insufficient_credits";
|
||||
@@ -26,6 +29,7 @@ export interface ChatUnlockPaywallRequest
|
||||
|
||||
export interface ChatState {
|
||||
characterId: string;
|
||||
emptyChatGreeting: string;
|
||||
messages: UiMessage[];
|
||||
promotion: ChatPromotionState | null;
|
||||
outgoingMessageRevision: number;
|
||||
@@ -55,9 +59,11 @@ export interface ChatState {
|
||||
|
||||
export function createInitialChatState(
|
||||
characterId: string = DEFAULT_CHARACTER_ID,
|
||||
emptyChatGreeting: string = DEFAULT_CHARACTER.emptyChatGreeting,
|
||||
): ChatState {
|
||||
return {
|
||||
characterId,
|
||||
emptyChatGreeting,
|
||||
messages: [],
|
||||
promotion: null,
|
||||
outgoingMessageRevision: 0,
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface LoadMoreHistoryActorEvent {
|
||||
|
||||
export interface ChatHistoryActorInput {
|
||||
characterId: string;
|
||||
emptyChatGreeting: string;
|
||||
}
|
||||
|
||||
export interface LoadMoreHistoryOutput {
|
||||
@@ -43,7 +44,10 @@ export const loadHistoryActor = fromCallback<
|
||||
|
||||
void (async () => {
|
||||
const cacheIdentity = await resolveHistoryCacheIdentity(input.characterId);
|
||||
const localSnapshot = await readLocalHistorySnapshot(cacheIdentity);
|
||||
const localSnapshot = await readLocalHistorySnapshot(
|
||||
cacheIdentity,
|
||||
input.emptyChatGreeting,
|
||||
);
|
||||
if (cancelled) return;
|
||||
sendBack({
|
||||
type: "ChatLocalHistoryLoaded",
|
||||
@@ -54,6 +58,7 @@ export const loadHistoryActor = fromCallback<
|
||||
input.characterId,
|
||||
localSnapshot.localCount,
|
||||
cacheIdentity,
|
||||
input.emptyChatGreeting,
|
||||
controller.signal,
|
||||
);
|
||||
if (cancelled || !networkSnapshot) return;
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface UnlockHistoryOutput {
|
||||
|
||||
export const unlockHistoryActor = fromPromise<
|
||||
UnlockHistoryOutput,
|
||||
{ characterId: string }
|
||||
{ characterId: string; emptyChatGreeting: string }
|
||||
>(async ({ input, signal }) => {
|
||||
const chatRepo = await loadChatRepository();
|
||||
const unlockResult = await chatRepo.unlockHistory(input.characterId, {
|
||||
@@ -40,7 +40,11 @@ export const unlockHistoryActor = fromPromise<
|
||||
}
|
||||
|
||||
signal.throwIfAborted();
|
||||
const history = await readAndSyncHistory(input.characterId, signal);
|
||||
const history = await readAndSyncHistory(
|
||||
input.characterId,
|
||||
input.emptyChatGreeting,
|
||||
signal,
|
||||
);
|
||||
return {
|
||||
unlocked: unlockResult.data.unlocked,
|
||||
reason: unlockResult.data.reason,
|
||||
|
||||
@@ -19,20 +19,26 @@ import {
|
||||
|
||||
const startGuestSessionAction = unlockMachineSetup.assign(
|
||||
({ context }) => ({
|
||||
...createInitialChatState(context.characterId),
|
||||
...createInitialChatState(
|
||||
context.characterId,
|
||||
context.emptyChatGreeting,
|
||||
),
|
||||
promotion: context.promotion,
|
||||
}),
|
||||
);
|
||||
|
||||
const startUserSessionAction = unlockMachineSetup.assign(
|
||||
({ context }) => ({
|
||||
...createInitialChatState(context.characterId),
|
||||
...createInitialChatState(
|
||||
context.characterId,
|
||||
context.emptyChatGreeting,
|
||||
),
|
||||
promotion: context.promotion,
|
||||
}),
|
||||
);
|
||||
|
||||
const clearChatSessionAction = unlockMachineSetup.assign(({ context }) => ({
|
||||
...createInitialChatState(context.characterId),
|
||||
...createInitialChatState(context.characterId, context.emptyChatGreeting),
|
||||
}));
|
||||
|
||||
const injectPromotionAction = unlockMachineSetup.assign(({ event }) => {
|
||||
@@ -80,12 +86,18 @@ const guestSessionState = chatMachineSetup.createStateConfig({
|
||||
{
|
||||
id: "loadHistory",
|
||||
src: "loadHistory",
|
||||
input: ({ context }) => ({ characterId: context.characterId }),
|
||||
input: ({ context }) => ({
|
||||
characterId: context.characterId,
|
||||
emptyChatGreeting: context.emptyChatGreeting,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "loadMoreHistory",
|
||||
src: "loadMoreHistory",
|
||||
input: ({ context }) => ({ characterId: context.characterId }),
|
||||
input: ({ context }) => ({
|
||||
characterId: context.characterId,
|
||||
emptyChatGreeting: context.emptyChatGreeting,
|
||||
}),
|
||||
},
|
||||
],
|
||||
on: {
|
||||
@@ -162,12 +174,18 @@ const userSessionState = chatMachineSetup.createStateConfig({
|
||||
{
|
||||
id: "loadHistory",
|
||||
src: "loadHistory",
|
||||
input: ({ context }) => ({ characterId: context.characterId }),
|
||||
input: ({ context }) => ({
|
||||
characterId: context.characterId,
|
||||
emptyChatGreeting: context.emptyChatGreeting,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "loadMoreHistory",
|
||||
src: "loadMoreHistory",
|
||||
input: ({ context }) => ({ characterId: context.characterId }),
|
||||
input: ({ context }) => ({
|
||||
characterId: context.characterId,
|
||||
emptyChatGreeting: context.emptyChatGreeting,
|
||||
}),
|
||||
},
|
||||
],
|
||||
on: {
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { ChatState } from "../chat-state";
|
||||
|
||||
export interface ChatMachineInput {
|
||||
characterId?: string;
|
||||
emptyChatGreeting?: string;
|
||||
}
|
||||
|
||||
export const baseChatMachineSetup = setup({
|
||||
|
||||
@@ -188,7 +188,10 @@ export const unlockingHistoryState = unlockMachineSetup.createStateConfig({
|
||||
invoke: {
|
||||
id: "unlockHistory",
|
||||
src: "unlockHistory",
|
||||
input: ({ context }) => ({ characterId: context.characterId }),
|
||||
input: ({ context }) => ({
|
||||
characterId: context.characterId,
|
||||
emptyChatGreeting: context.emptyChatGreeting,
|
||||
}),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: applyUnlockHistoryOutputAction,
|
||||
|
||||
Reference in New Issue
Block a user