112 lines
3.8 KiB
TypeScript
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,
|
|
};
|
|
}
|