Files
cozsweet-frontend-nextjs/src/stores/chat/chat-context.tsx
T

112 lines
3.8 KiB
TypeScript

"use client";
import { type Dispatch, type ReactNode, useMemo } from "react";
import { createActorContext, shallowEqual } from "@xstate/react";
import type { SnapshotFrom } from "xstate";
import { chatMachine } from "./chat-machine";
import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
import { appendPromotionMessage } from "./helper/promotion";
/**
* 对外暴露的 State 形状
*
* isGuest 字段已删 —— 由 chat-screen 派生自 `auth.loginStatus === "guest"`。
* 消费方(chat-screen)通过 `useAuthState()` 取 loginStatus,本地派生 isGuest。
*/
interface ChatState {
characterId: string;
messages: MachineContext["messages"];
historyMessages: MachineContext["messages"];
promotion: MachineContext["promotion"];
outgoingMessageRevision: number;
isReplyingAI: boolean;
upgradePromptVisible: boolean;
upgradeReason: MachineContext["upgradeReason"];
/** history 初始加载完成标志(local → network → save 跑完) —— UI 可用此显示 loading */
historyLoaded: boolean;
hasMoreHistory: boolean;
isLoadingMoreHistory: boolean;
unlockHistoryPromptVisible: boolean;
lockedHistoryCount: number;
isUnlockingHistory: boolean;
unlockHistoryError: string | null;
isUnlockingMessage: boolean;
unlockingMessageId: string | null;
unlockMessageError: string | null;
unlockPaywallRequest: MachineContext["unlockPaywallRequest"];
}
type ChatSnapshot = SnapshotFrom<typeof chatMachine>;
type ChatSelector<T> = (snapshot: ChatSnapshot) => T;
const ChatActorContext = createActorContext(chatMachine);
export interface ChatProviderProps {
children: ReactNode;
characterId: string;
emptyChatGreeting: string;
}
export function ChatProvider({
children,
characterId,
emptyChatGreeting,
}: ChatProviderProps) {
return (
<ChatActorContext.Provider
options={{ input: { characterId, emptyChatGreeting } }}
>
{children}
</ChatActorContext.Provider>
);
}
export function useChatState(): ChatState {
const selected = useChatSelector(selectChatState, shallowEqual);
const messages = useMemo(
() => appendPromotionMessage(selected.historyMessages, selected.promotion),
[selected.historyMessages, selected.promotion],
);
return useMemo(() => ({ ...selected, messages }), [messages, selected]);
}
export function useChatDispatch(): Dispatch<ChatEvent> {
return ChatActorContext.useActorRef().send;
}
export function useChatSelector<T>(
selector: ChatSelector<T>,
compare?: (previous: T, next: T) => boolean,
): T {
return ChatActorContext.useSelector(selector, compare);
}
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,
isReplyingAI: state.context.isReplyingAI,
upgradePromptVisible: state.context.upgradePromptVisible,
upgradeReason: state.context.upgradeReason,
historyLoaded: state.context.historyLoaded,
hasMoreHistory:
state.context.historyLoaded &&
state.context.nextHistoryOffset < state.context.historyTotal,
isLoadingMoreHistory: state.context.isLoadingMoreHistory,
unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible,
lockedHistoryCount: state.context.lockedHistoryCount,
isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }),
unlockHistoryError: state.context.unlockHistoryError,
isUnlockingMessage: state.matches({ userSession: "unlockingMessage" }),
unlockingMessageId:
state.context.unlockingMessage?.displayMessageId ?? null,
unlockMessageError: state.context.unlockMessageError,
unlockPaywallRequest: state.context.unlockPaywallRequest,
};
}