feat: add xstate state management for user state

Introduce xstate v5 and @xstate/react to manage user authentication and profile state. Implement userMachine with actors for initialization, fetching, and logout operations, along with actions for updating user data and clearing state.
This commit is contained in:
2026-06-09 17:26:16 +08:00
parent 50940961ec
commit 5e645b003e
33 changed files with 1466 additions and 1273 deletions
+371
View File
@@ -0,0 +1,371 @@
/**
* Chat 状态机(XState v5
*
* 原始 Dart: lib/ui/chat/bloc/chat bloc + chat state + chat event
*
* 本轮迁移范围:
* ✅ HTTP 流程(init / send message / load more history
* ✅ 上下文数据(messages / quota / 标志位)
* ⏳ WebSocket 长生命周期 actorfromCallback)—— 下轮处理
*
* 设计要点:
* - 使用 XState v5 `setup({...}).createMachine({...})` 声明式 API
* - HTTP 操作用 `fromPromise` actor(一次性 Promise
* - 保持事件类型名与原 Dart 一致,便于业务层迁移对照
* - actions 全部 inline 在 setup() 中(确保类型推断正确)
*/
import { setup, fromPromise, assign } from "xstate";
import type { UiMessage } from "@/models/chat/ui-message";
import { chatRepository } from "@/data/repositories/chat_repository";
import { ChatStorage } from "@/data/storage/chat/chat_storage";
import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { formatDate } from "@/utils/date";
import { Result } from "@/utils/result";
// ============================================================
// Context
// ============================================================
export interface ChatContext {
messages: UiMessage[];
isReplyingAI: boolean;
isGuest: boolean;
guestRemainingQuota: number;
guestTotalQuota: number;
quotaExceededTrigger: number;
isLoadingMore: boolean;
hasMore: boolean;
historyOffset: number;
}
const initialContext: ChatContext = {
messages: [],
isReplyingAI: false,
isGuest: true,
guestRemainingQuota: 40,
guestTotalQuota: 50,
quotaExceededTrigger: 0,
isLoadingMore: false,
hasMore: true,
historyOffset: 0,
};
/** 游客配额阈值(与 Dart `GuestChatQuota` 保持一致) */
export const GuestChatQuota = {
warningThreshold: 5,
quotaExhausted: 0,
} as const;
// ============================================================
// Events
// ============================================================
export type ChatEvent =
| { type: "ChatInit" }
| { type: "ChatScreenVisible" }
| { type: "ChatScreenInvisible" }
| { type: "ChatSendMessage"; content: string }
| { type: "ChatSendImage"; imageBase64: string }
| { type: "ChatLoadMoreHistory" }
| {
type: "ChatAISentenceReceived";
index: number;
text: string;
total: number;
done: boolean;
}
| { type: "ChatWebSocketError"; errorMessage: string }
| { type: "ChatWebSocketConnected"; userId: string }
| { type: "ChatQuotaExceeded" }
| { type: "ChatAuthStatusChanged" };
// ============================================================
// Helpers
// ============================================================
const PAGE_SIZE = 20;
function localMessagesToUi(
records: ReadonlyArray<{
content: string;
role: string;
createdAt: string;
}>,
): UiMessage[] {
return records.map((m) => ({
content: m.content,
isFromAI: m.role === "assistant",
date: m.createdAt,
}));
}
function mapQuotaResult(
r: Result<{ remaining: number } | null>,
fallback: number,
): number {
if (r.success && r.data != null) return r.data.remaining;
return fallback;
}
function mapTotalQuotaResult(
r: Result<number | null>,
fallback: number,
): number {
if (r.success && r.data != null) return r.data;
return fallback;
}
interface InitResult {
localMessages: UiMessage[];
isGuest: boolean;
guestRemainingQuota: number;
guestTotalQuota: number;
}
async function readInitData(): Promise<InitResult> {
const chatStorage = ChatStorage.getInstance();
const isGuest = !AuthStorage.getInstance().hasLoginToken();
const [dailyResult, totalResult, localResult] = await Promise.all([
chatStorage.getGuestDailyChatQuota(),
chatStorage.getGuestTotalQuota(),
isGuest ? chatRepository.getLocalMessages() : Promise.resolve(null),
]);
return {
isGuest,
guestRemainingQuota: mapQuotaResult(
dailyResult as Result<{ remaining: number } | null>,
40,
),
guestTotalQuota: mapTotalQuotaResult(
totalResult as Result<number | null>,
50,
),
localMessages:
localResult && Result.isOk(localResult) && localResult.data
? localMessagesToUi(localResult.data)
: [],
};
}
// ============================================================
// Actors
// ============================================================
const chatInitActor = fromPromise<InitResult>(async () => readInitData());
const sendMessageHttpActor = fromPromise<
{ messages: UiMessage[] },
{ content: string }
>(async ({ input }) => {
const result = await chatRepository.sendMessage(input.content);
if (Result.isErr(result)) throw result.error;
// 拉取最新本地历史
const local = await chatRepository.getLocalMessages();
if (Result.isOk(local) && local.data) {
return { messages: localMessagesToUi(local.data) };
}
return { messages: [] };
});
const loadMoreHistoryActor = fromPromise<
{ messages: UiMessage[]; hasMore: boolean; newOffset: number },
{ offset: number }
>(async ({ input }) => {
const result = await chatRepository.getHistory(PAGE_SIZE, input.offset);
if (Result.isErr(result)) throw result.error;
const page = localMessagesToUi(result.data.messages);
return {
messages: page,
hasMore: page.length >= PAGE_SIZE,
newOffset: input.offset + page.length,
};
});
// ============================================================
// Machine
// ============================================================
export const chatMachine = setup({
types: {
context: {} as ChatContext,
events: {} as ChatEvent,
},
actors: {
chatInit: chatInitActor,
sendMessageHttp: sendMessageHttpActor,
loadMoreHistory: loadMoreHistoryActor,
},
actions: {
appendUserMessage: assign(({ context, event }) => {
if (event.type !== "ChatSendMessage") return {};
return {
messages: [
...context.messages,
{
content: event.content,
isFromAI: false,
date: formatDate(),
},
],
isReplyingAI: true,
};
}),
appendUserImage: assign(({ context, event }) => {
if (event.type !== "ChatSendImage") return {};
return {
messages: [
...context.messages,
{
content: "[Image]",
isFromAI: false,
date: formatDate(),
imageUrl: event.imageBase64,
},
],
isReplyingAI: true,
};
}),
appendOrUpdateAISentence: assign(({ context, event }) => {
if (event.type !== "ChatAISentenceReceived") return {};
const messages = [...context.messages];
if (event.index === 0) {
messages.push({
content: event.text,
isFromAI: true,
date: formatDate(),
});
} else {
const last = messages[messages.length - 1];
if (last && last.isFromAI) {
messages[messages.length - 1] = {
...last,
content: `${last.content} ${event.text}`,
};
}
}
return { messages, isReplyingAI: !event.done };
}),
appendSocketErrorMessage: assign(({ context }) => {
const messages = [
...context.messages,
{
content: "Something went wrong. Try sending again?",
isFromAI: true,
date: formatDate(),
},
];
return { messages, isReplyingAI: false };
}),
refreshAuthStatus: assign(() => ({
isGuest: !AuthStorage.getInstance().hasLoginToken(),
})),
incrementQuotaExceeded: assign(({ context }) => ({
quotaExceededTrigger: context.quotaExceededTrigger + 1,
})),
},
}).createMachine({
id: "chat",
initial: "idle",
context: initialContext,
states: {
idle: {
on: {
ChatInit: "initializing",
ChatScreenVisible: "ready",
ChatLoadMoreHistory: "loadingMoreHistory",
ChatSendMessage: {
guard: ({ event }) => event.content.trim().length > 0,
actions: "appendUserMessage",
},
ChatSendImage: {
actions: "appendUserImage",
},
ChatAuthStatusChanged: {
actions: "refreshAuthStatus",
},
ChatAISentenceReceived: {
actions: "appendOrUpdateAISentence",
},
ChatWebSocketError: {
actions: "appendSocketErrorMessage",
},
ChatQuotaExceeded: {
actions: "incrementQuotaExceeded",
},
ChatWebSocketConnected: {},
},
},
initializing: {
invoke: {
src: "chatInit",
onDone: {
target: "ready",
actions: assign({
messages: ({ event }) => event.output.localMessages,
isGuest: ({ event }) => event.output.isGuest,
guestRemainingQuota: ({ event }) => event.output.guestRemainingQuota,
guestTotalQuota: ({ event }) => event.output.guestTotalQuota,
}),
},
onError: {
target: "ready",
},
},
},
ready: {
on: {
ChatSendMessage: {
guard: ({ event }) => event.content.trim().length > 0,
actions: "appendUserMessage",
},
ChatSendImage: {
actions: "appendUserImage",
},
ChatLoadMoreHistory: "loadingMoreHistory",
ChatScreenInvisible: "idle",
ChatAuthStatusChanged: {
actions: "refreshAuthStatus",
},
ChatAISentenceReceived: {
actions: "appendOrUpdateAISentence",
},
ChatWebSocketError: {
actions: "appendSocketErrorMessage",
},
ChatQuotaExceeded: {
actions: "incrementQuotaExceeded",
},
ChatWebSocketConnected: {},
},
},
loadingMoreHistory: {
invoke: {
src: "loadMoreHistory",
input: ({ context }) => ({ offset: context.historyOffset }),
onDone: {
target: "ready",
actions: assign(({ context, event }) => ({
messages: [...event.output.messages, ...context.messages],
isLoadingMore: false,
hasMore: event.output.hasMore,
historyOffset: event.output.newOffset,
})),
},
onError: {
target: "ready",
actions: assign({ isLoadingMore: false }),
},
},
},
},
});
export type ChatMachine = typeof chatMachine;