feat(chat): sync multi-role backend APIs

This commit is contained in:
2026-07-20 11:29:54 +08:00
parent 16b5c16e76
commit b6fdc912ae
84 changed files with 1488 additions and 439 deletions
@@ -27,7 +27,7 @@ vi.mock("@/data/repositories/chat_repository_loader", () => ({
vi.mock("@/data/repositories/chat_cache_identity", () => ({
resolveChatConversationKey: vi.fn(async () => ({
success: true,
data: "user:test::character:character_elio",
data: "user:test::character:elio",
})),
}));
@@ -47,7 +47,7 @@ describe("chat actor request cancellation", () => {
it("aborts an in-flight send request when its actor stops", async () => {
repository.sendMessage.mockImplementation(() => new Promise(() => undefined));
const actor = createActor(sendMessageHttpActor, {
input: { characterId: "character_elio", content: "hello" },
input: { characterId: "elio", content: "hello" },
}).start();
await vi.waitFor(() => expect(repository.sendMessage).toHaveBeenCalled());
@@ -63,7 +63,7 @@ describe("chat actor request cancellation", () => {
);
const actor = createActor(loadHistoryActor, {
input: {
characterId: "character_elio",
characterId: "elio",
emptyChatGreeting: "Hello from Elio",
},
}).start();
@@ -85,7 +85,7 @@ describe("chat actor request cancellation", () => {
);
const actor = createActor(unlockHistoryActor, {
input: {
characterId: "character_elio",
characterId: "elio",
emptyChatGreeting: "Hello from Elio",
},
}).start();
@@ -38,6 +38,7 @@ function makeResponse(
function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
return {
characterId: DEFAULT_CHARACTER_ID,
characterErrorCode: null,
emptyChatGreeting: DEFAULT_CHARACTER.emptyChatGreeting,
messages: [],
promotion: null,
@@ -80,6 +80,7 @@ export function createTestChatMachine(
unlockHistoryOutput?: UnlockHistoryOutput;
unlockHistoryError?: Error;
unlockMessageOutput?: TestUnlockMessageOutput;
unlockMessageError?: Error;
} = {},
) {
return chatMachine.provide({
@@ -134,6 +135,7 @@ export function createTestChatMachine(
MachineUnlockMessageOutput,
UnlockMessageRequest & { characterId: string }
>(async ({ input }) => {
if (options.unlockMessageError) throw options.unlockMessageError;
const output = options.unlockMessageOutput ?? {
messageId: input.messageId ?? input.displayMessageId,
response: makeUnlockPrivateResponse(),
@@ -9,7 +9,7 @@ import {
} from "@/stores/chat/helper/promotion";
const promotion: PendingChatPromotion = {
characterId: "character_elio",
characterId: "elio",
promotionType: "image",
lockType: "image_paywall",
clientLockId: "promotion-1",
@@ -10,13 +10,13 @@ describe("chat session flow", () => {
it("initializes the conversation from the supplied character", () => {
const actor = createActor(createTestChatMachine(), {
input: {
characterId: "character_maya",
characterId: "maya-tan",
emptyChatGreeting: "Hello from Maya",
},
}).start();
expect(actor.getSnapshot().context).toMatchObject({
characterId: "character_maya",
characterId: "maya-tan",
emptyChatGreeting: "Hello from Maya",
});
actor.stop();
@@ -25,7 +25,7 @@ describe("chat session flow", () => {
it("keeps the active character greeting across login state changes", async () => {
const actor = createActor(createTestChatMachine(), {
input: {
characterId: "character_maya",
characterId: "maya-tan",
emptyChatGreeting: "Hello from Maya",
},
}).start();
@@ -113,7 +113,7 @@ describe("chat session flow", () => {
actor.send({
type: "ChatPromotionInjected",
promotion: {
characterId: "character_elio",
characterId: "elio",
promotionType: "voice",
lockType: "voice_message",
clientLockId: "promotion-1",
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { createActor, fromPromise, waitFor } from "xstate";
import { ChatSendResponseSchema } from "@/data/schemas/chat";
import { ApiError } from "@/data/services/api";
import type {
UnlockMessageOutput,
UnlockMessageRequest,
@@ -565,6 +566,50 @@ describe("chat unlock flow", () => {
actor.stop();
});
it("refreshes history instead of opening payment on character mismatch", async () => {
const actor = createActor(
createTestChatMachine({
historyMessages: [
{
id: "msg-mismatch",
content: "",
isFromAI: true,
date: "2026-07-20",
locked: true,
lockReason: "voice_message",
},
],
unlockMessageError: new ApiError("HTTP_ERROR", "Mismatch", 409, {
detail: { errorCode: "CHARACTER_MISMATCH" },
}),
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
actor.send({
type: "ChatUnlockMessageRequested",
messageId: "msg-mismatch",
kind: "voice",
});
await waitFor(
actor,
(snapshot) =>
snapshot.context.characterErrorCode === "CHARACTER_MISMATCH",
);
expect(actor.getSnapshot().context.unlockPaywallRequest).toBeNull();
actor.send({ type: "ChatHistoryRefreshRequested" });
await waitFor(
actor,
(snapshot) => snapshot.context.characterErrorCode === null,
);
actor.stop();
});
it("owns the active request in one object and ignores history refreshes while unlocking", async () => {
let capturedRequest: UnlockMessageRequest | null = null;
let resolveUnlock!: (output: UnlockMessageOutput) => void;
@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Result } from "@/utils/result";
const mocks = vi.hoisted(() => ({
getDeviceId: vi.fn(),
getLocalMessages: vi.fn(),
syncGuestHistory: vi.fn(),
clearLocalMessages: vi.fn(),
getGuestChatOwnerKey: vi.fn(),
}));
vi.mock("@/data/storage/auth", () => ({
AuthStorage: {
getInstance: () => ({ getDeviceId: mocks.getDeviceId }),
},
}));
vi.mock("@/data/repositories/chat_repository_loader", () => ({
loadChatRepository: async () => ({
getLocalMessages: mocks.getLocalMessages,
syncGuestHistory: mocks.syncGuestHistory,
clearLocalMessages: mocks.clearLocalMessages,
}),
}));
vi.mock("@/data/storage/navigation", () => ({
NavigationStorage: {
getGuestChatOwnerKey: mocks.getGuestChatOwnerKey,
},
}));
import { syncGuestHistoriesToUser } from "../guest-history-sync";
describe("syncGuestHistoriesToUser", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getDeviceId.mockResolvedValue(Result.ok("device-1"));
mocks.getGuestChatOwnerKey.mockResolvedValue(null);
mocks.clearLocalMessages.mockResolvedValue(Result.ok(undefined));
});
it("groups guest history by character and clears only successful caches", async () => {
mocks.getLocalMessages.mockImplementation(async (identity: string) =>
Result.ok([
{
id: `${identity}:message`,
role: "user",
type: "text",
content: identity,
createdAt: "2026-07-20T00:00:00.000Z",
},
]),
);
mocks.syncGuestHistory.mockImplementation(
async (request: { characterId: string }) =>
request.characterId === "elio"
? Result.ok(undefined)
: Result.err(new Error("offline")),
);
await syncGuestHistoriesToUser(["elio", "maya-tan"]);
expect(mocks.syncGuestHistory).toHaveBeenCalledTimes(2);
expect(mocks.syncGuestHistory).toHaveBeenCalledWith(
expect.objectContaining({ characterId: "elio" }),
);
expect(mocks.syncGuestHistory).toHaveBeenCalledWith(
expect.objectContaining({ characterId: "maya-tan" }),
);
expect(mocks.clearLocalMessages).toHaveBeenCalledOnce();
expect(mocks.clearLocalMessages).toHaveBeenCalledWith(
"device:device-1::character:elio",
);
});
it("skips characters without guest messages", async () => {
mocks.getLocalMessages.mockResolvedValue(Result.ok([]));
await syncGuestHistoriesToUser(["elio"]);
expect(mocks.syncGuestHistory).not.toHaveBeenCalled();
});
});
+4
View File
@@ -16,11 +16,13 @@ import { appendPromotionMessage } from "./helper/promotion";
*/
interface ChatState {
characterId: string;
characterErrorCode: MachineContext["characterErrorCode"];
messages: MachineContext["messages"];
historyMessages: MachineContext["messages"];
promotion: MachineContext["promotion"];
outgoingMessageRevision: number;
isReplyingAI: boolean;
canSendMessage: boolean;
upgradePromptVisible: boolean;
upgradeReason: MachineContext["upgradeReason"];
/** history 初始加载完成标志(local → network → save 跑完) —— UI 可用此显示 loading */
@@ -87,10 +89,12 @@ type SelectedChatState = Omit<ChatState, "messages">;
function selectChatState(state: ChatSnapshot): SelectedChatState {
return {
characterId: state.context.characterId,
characterErrorCode: state.context.characterErrorCode,
historyMessages: state.context.messages,
promotion: state.context.promotion,
outgoingMessageRevision: state.context.outgoingMessageRevision,
isReplyingAI: state.context.isReplyingAI,
canSendMessage: state.context.canSendMessage,
upgradePromptVisible: state.context.upgradePromptVisible,
upgradeReason: state.context.upgradeReason,
historyLoaded: state.context.historyLoaded,
+7 -1
View File
@@ -39,6 +39,7 @@ export type ChatEvent =
output: import("./machine/actors/history").LoadMoreHistoryOutput;
}
| { type: "ChatOlderHistoryLoadFailed"; error: unknown }
| { type: "ChatHistoryRefreshRequested" }
// 业务事件
| { type: "ChatSendMessage"; content: string }
| { type: "ChatSendImage"; imageBase64: string }
@@ -65,4 +66,9 @@ export type ChatEvent =
type: "ChatQueuedHttpDone";
output: import("./helper/send-state").HttpSendOutput;
}
| { type: "ChatQueuedSendError"; content: string; errorMessage: string };
| {
type: "ChatQueuedSendError";
content: string;
errorMessage: string;
characterErrorCode?: import("@/data/services/api").CharacterErrorCode;
};
+2
View File
@@ -5,6 +5,7 @@ import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result";
import { todayString } from "@/utils/date";
import { isAbortError } from "@/utils/abort";
import { getCharacterErrorCode } from "@/data/services/api";
import { CHAT_HISTORY_LIMIT } from "./helper/history";
import { localMessagesToUi } from "./helper/message-mappers";
@@ -94,6 +95,7 @@ export async function syncNetworkHistory(
);
if (Result.isErr(networkResult)) {
if (isAbortError(networkResult.error)) throw networkResult.error;
if (getCharacterErrorCode(networkResult.error)) throw networkResult.error;
log.error("[chat-machine] loadHistory NETWORK FAILED", {
error: networkResult.error,
});
+2 -7
View File
@@ -2,11 +2,6 @@ import {
chatMachineSetup,
chatRootStateConfig,
} from "./machine/session-flow";
import {
DEFAULT_CHARACTER,
DEFAULT_CHARACTER_ID,
} from "@/data/constants/character";
import { createInitialChatState } from "./chat-state";
export type { ChatState } from "./chat-state";
@@ -17,8 +12,8 @@ export const chatMachine = chatMachineSetup.createMachine({
id: "chat",
context: ({ input }) =>
createInitialChatState(
input?.characterId ?? DEFAULT_CHARACTER_ID,
input?.emptyChatGreeting ?? DEFAULT_CHARACTER.emptyChatGreeting,
input.characterId,
input.emptyChatGreeting,
),
...chatRootStateConfig,
});
+9 -3
View File
@@ -7,6 +7,7 @@ import {
DEFAULT_CHARACTER_ID,
} from "@/data/constants/character";
import type { ChatPromotionState } from "./helper/promotion";
import type { CharacterErrorCode } from "@/data/services/api";
export type ChatUpgradeReason = "insufficient_credits";
@@ -29,6 +30,7 @@ export interface ChatUnlockPaywallRequest
export interface ChatState {
characterId: string;
characterErrorCode: CharacterErrorCode | null;
emptyChatGreeting: string;
messages: UiMessage[];
promotion: ChatPromotionState | null;
@@ -58,11 +60,12 @@ export interface ChatState {
}
export function createInitialChatState(
characterId: string = DEFAULT_CHARACTER_ID,
emptyChatGreeting: string = DEFAULT_CHARACTER.emptyChatGreeting,
characterId: string,
emptyChatGreeting: string,
): ChatState {
return {
characterId,
characterErrorCode: null,
emptyChatGreeting,
messages: [],
promotion: null,
@@ -91,4 +94,7 @@ export function createInitialChatState(
};
}
export const initialState: ChatState = createInitialChatState();
export const initialState: ChatState = createInitialChatState(
DEFAULT_CHARACTER_ID,
DEFAULT_CHARACTER.emptyChatGreeting,
);
+65
View File
@@ -0,0 +1,65 @@
import { AuthStorage } from "@/data/storage/auth";
import { NavigationStorage } from "@/data/storage/navigation";
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
import { ChatSyncRequestSchema } from "@/data/schemas/chat";
import { buildChatConversationKey } from "@/lib/chat/chat_cache_keys";
import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result";
const log = new Logger("StoresChatGuestHistorySync");
export async function syncGuestHistoriesToUser(
characterIds: readonly string[],
): Promise<void> {
const deviceResult = await AuthStorage.getInstance().getDeviceId();
const savedOwnerKey = await NavigationStorage.getGuestChatOwnerKey();
const ownerKey =
savedOwnerKey ??
(Result.isOk(deviceResult) && deviceResult.data
? `device:${deviceResult.data}`
: null);
if (!ownerKey) return;
const repository = await loadChatRepository();
await Promise.allSettled(
[...new Set(characterIds)].map(async (characterId) => {
const cacheIdentity = buildChatConversationKey(ownerKey, characterId);
const messagesResult = await repository.getLocalMessages(cacheIdentity);
if (Result.isErr(messagesResult) || messagesResult.data.length === 0) return;
const messages = messagesResult.data.flatMap((message) => {
if (message.role !== "user" && message.role !== "assistant") return [];
return [{
role: message.role,
content: message.content,
timestamp: normalizeTimestamp(message.createdAt),
}];
});
if (messages.length === 0) return;
const request = ChatSyncRequestSchema.parse({ characterId, messages });
const syncResult = await repository.syncGuestHistory(request);
if (Result.isErr(syncResult)) {
log.warn("[chat-sync] guest history sync failed", {
characterId,
error: syncResult.error,
});
return;
}
const clearResult = await repository.clearLocalMessages(cacheIdentity);
if (Result.isErr(clearResult)) {
log.warn("[chat-sync] guest history cleanup failed", {
characterId,
error: clearResult.error,
});
}
}),
);
}
function normalizeTimestamp(value: string): string {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime())
? new Date().toISOString()
: parsed.toISOString();
}
+4
View File
@@ -8,6 +8,7 @@ import { resolveChatConversationKey } from "@/data/repositories/chat_cache_ident
import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result";
import { isAbortError } from "@/utils/abort";
import { getCharacterErrorCode } from "@/data/services/api";
import type { ChatEvent } from "../../chat-events";
import { sendResponseToUiMessage } from "../../helper/message-mappers";
@@ -61,6 +62,9 @@ function createMessageQueueActor(
type: "ChatQueuedSendError",
content,
errorMessage,
...(getCharacterErrorCode(error)
? { characterErrorCode: getCharacterErrorCode(error) ?? undefined }
: {}),
});
} finally {
if (activeController === controller) activeController = null;
+16 -3
View File
@@ -10,6 +10,7 @@ import {
} from "../helper/unlock";
import type { ChatState } from "../chat-state";
import { baseChatMachineSetup } from "./setup";
import { getCharacterErrorCode } from "@/data/services/api";
const applyLocalHistoryLoadedAction = baseChatMachineSetup.assign(
({ event }) => {
@@ -48,9 +49,21 @@ const showUnlockHistoryPromptFromNetworkAction = baseChatMachineSetup.assign(
},
);
const markHistoryLoadFailedAction = baseChatMachineSetup.assign({
historyLoaded: true,
});
const markHistoryLoadFailedAction = baseChatMachineSetup.assign(
({ context, event }) => {
if (event.type !== "ChatHistoryLoadFailed") return {};
const characterErrorCode = getCharacterErrorCode(event.error);
return {
historyLoaded: true,
characterErrorCode,
canSendMessage:
characterErrorCode === "CHARACTER_DISABLED" ||
characterErrorCode === "CHARACTER_NOT_FOUND"
? false
: context.canSendMessage,
};
},
);
const markLoadMoreHistoryStartedAction = baseChatMachineSetup.assign({
isLoadingMoreHistory: true,
+17 -3
View File
@@ -76,16 +76,30 @@ const appendUserMessageAction = historyMachineSetup.assign(
);
const appendQueuedSendErrorMessageAction = historyMachineSetup.assign(
({ context }) => {
({ context, event }) => {
if (event.type !== "ChatQueuedSendError") return {};
const characterErrorCode = event.characterErrorCode ?? null;
const unavailable = characterErrorCode === "CHARACTER_STATE_UNAVAILABLE";
const messages = [
...context.messages,
{
content: "Something went wrong. Try sending again?",
content: unavailable
? "This character is temporarily unavailable. Please try again shortly."
: "Something went wrong. Try sending again?",
isFromAI: true,
date: todayString(),
},
];
return { messages, ...finishPendingReply(context) };
return {
messages,
...finishPendingReply(context),
characterErrorCode,
canSendMessage:
characterErrorCode === "CHARACTER_DISABLED" ||
characterErrorCode === "CHARACTER_NOT_FOUND"
? false
: context.canSendMessage,
};
},
);
+5
View File
@@ -189,6 +189,11 @@ const userSessionState = chatMachineSetup.createStateConfig({
},
],
on: {
ChatHistoryRefreshRequested: {
target: "#chat.userSession",
reenter: true,
actions: "startUserSession",
},
...historyPaginationTransitions,
ChatGuestLogin: {
target: "#chat.guestSession",
+3 -3
View File
@@ -16,15 +16,15 @@ import {
import type { ChatState } from "../chat-state";
export interface ChatMachineInput {
characterId?: string;
emptyChatGreeting?: string;
characterId: string;
emptyChatGreeting: string;
}
export const baseChatMachineSetup = setup({
types: {
context: {} as ChatState,
events: {} as ChatEvent,
input: {} as ChatMachineInput | undefined,
input: {} as ChatMachineInput,
},
actors: {
loadHistory: loadHistoryActor,
+21 -4
View File
@@ -9,6 +9,7 @@ import {
import type { UnlockHistoryOutput } from "./actors/unlock";
import { sendMachineSetup } from "./send-flow";
import { createChatActorActionSetup } from "./setup";
import { getCharacterErrorCode } from "@/data/services/api";
const markPaymentUnlockPendingAction = sendMachineSetup.assign(() => ({
paymentUnlockPending: true,
@@ -146,6 +147,13 @@ const clearUnlockPaywallRequestAction = sendMachineSetup.assign(() => ({
unlockPaywallRequest: null,
}));
const handleCharacterMismatchAction = sendMachineSetup.assign(() => ({
unlockingMessage: null,
unlockMessageError: "character_mismatch",
unlockPaywallRequest: null,
characterErrorCode: "CHARACTER_MISMATCH" as const,
}));
export const unlockMachineSetup = sendMachineSetup.extend({
actions: {
markPaymentUnlockPending: markPaymentUnlockPendingAction,
@@ -158,6 +166,7 @@ export const unlockMachineSetup = sendMachineSetup.extend({
requestUnlockPaymentFromOutput: requestUnlockPaymentFromOutputAction,
requestUnlockPaymentFromError: requestUnlockPaymentFromErrorAction,
clearUnlockPaywallRequest: clearUnlockPaywallRequestAction,
handleCharacterMismatch: handleCharacterMismatchAction,
},
});
@@ -225,9 +234,17 @@ export const unlockingMessageState = unlockMachineSetup.createStateConfig({
actions: "requestUnlockPaymentFromOutput",
},
],
onError: {
target: "ready",
actions: "requestUnlockPaymentFromError",
},
onError: [
{
guard: ({ event }) =>
getCharacterErrorCode(event.error) === "CHARACTER_MISMATCH",
target: "ready",
actions: "handleCharacterMismatch",
},
{
target: "ready",
actions: "requestUnlockPaymentFromError",
},
],
},
});
@@ -87,7 +87,7 @@ describe("payment order flow", () => {
await initialize(actor);
actor.send({
type: "PaymentCreateOrderSubmitted",
recipientCharacterId: "character_maya",
recipientCharacterId: "maya-tan",
});
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
@@ -95,7 +95,7 @@ describe("payment order flow", () => {
planId: "vip_monthly",
payChannel: "stripe",
autoRenew: true,
recipientCharacterId: "character_maya",
recipientCharacterId: "maya-tan",
});
actor.stop();
});
+1 -1
View File
@@ -9,7 +9,7 @@ import {
} from "./actors/albums";
export interface PrivateRoomMachineInput {
characterId?: string;
characterId: string;
}
export const basePrivateRoomMachineSetup = setup({
@@ -2,8 +2,6 @@ import {
privateRoomMachineSetup,
privateRoomRootStateConfig,
} from "./machine/unlock-flow";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { createInitialPrivateRoomState } from "./private-room-state";
export type { PrivateRoomEvent } from "./private-room-events";
@@ -13,9 +11,7 @@ export { initialState } from "./private-room-state";
export const privateRoomMachine = privateRoomMachineSetup.createMachine({
id: "privateRoom",
context: ({ input }) =>
createInitialPrivateRoomState(
input?.characterId ?? DEFAULT_CHARACTER_ID,
),
createInitialPrivateRoomState(input.characterId),
...privateRoomRootStateConfig,
});
@@ -22,7 +22,7 @@ export interface PrivateRoomState {
}
export function createInitialPrivateRoomState(
characterId = DEFAULT_CHARACTER_ID,
characterId: string,
): PrivateRoomState {
return {
characterId,
@@ -37,4 +37,4 @@ export function createInitialPrivateRoomState(
};
}
export const initialState = createInitialPrivateRoomState();
export const initialState = createInitialPrivateRoomState(DEFAULT_CHARACTER_ID);
+22 -2
View File
@@ -16,6 +16,11 @@ import {
useAuthSelector,
} from "@/stores/auth/auth-context";
import { useChatDispatch } from "@/stores/chat/chat-context";
import { syncGuestHistoriesToUser } from "@/stores/chat/guest-history-sync";
import { useCharacterCatalog } from "@/providers/character-catalog-provider";
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
import { NavigationStorage } from "@/data/storage/navigation";
import { Result } from "@/utils/result";
export function ChatAuthSync() {
const authState = useAuthSelector(
@@ -27,6 +32,7 @@ export function ChatAuthSync() {
shallowEqual,
);
const chatDispatch = useChatDispatch();
const characterCatalog = useCharacterCatalog();
const prevSessionKeyRef = useRef<string | null>(null);
useEffect(() => {
@@ -42,14 +48,27 @@ export function ChatAuthSync() {
}
if (authState.loginStatus === "guest") {
chatDispatch({ type: "ChatGuestLogin" });
return;
let cancelled = false;
void (async () => {
const ownerResult = await resolveChatCacheOwnerKey();
if (Result.isOk(ownerResult)) {
await NavigationStorage.saveGuestChatOwnerKey(ownerResult.data);
}
if (!cancelled) chatDispatch({ type: "ChatGuestLogin" });
})();
return () => {
cancelled = true;
};
}
let cancelled = false;
void (async () => {
const tokenR = await AuthStorage.getInstance().getLoginToken();
if (!cancelled && tokenR.success && tokenR.data) {
await syncGuestHistoriesToUser(
characterCatalog.characters.map((character) => character.id),
);
if (cancelled) return;
chatDispatch({
type: "ChatUserLogin",
token: tokenR.data,
@@ -64,6 +83,7 @@ export function ChatAuthSync() {
authState.hasInitialized,
authState.isLoading,
authState.loginStatus,
characterCatalog.characters,
chatDispatch,
]);