ed4ccddae3
- Remove ChatAuthStatusChanged dispatch from auth login flow; chat screen now subscribes directly to auth state machine (loginStatus) to derive isGuest and drive WS lifecycle - Gate quota-exceeded and warning dialogs to guests only (non-guests have no message limit) - Dispatch ChatLoadMoreHistory for non-guest initial load vs ChatInit for guests - Fix AppConstants import path from @/core/constants/app_constants to @/core/app_constants - Add defensive isGuest checks in quota effects to prevent non-guest quota triggers
348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
/**
|
||
* Chat 状态机(XState v5)
|
||
*
|
||
* 原始 Dart: lib/ui/chat/bloc/chat bloc + chat state + chat event
|
||
*
|
||
* 本轮迁移范围:
|
||
* ✅ HTTP 流程(init / send message / load more history)
|
||
* ✅ 上下文数据(messages / quota / 标志位)
|
||
* ⏳ WebSocket 长生命周期 actor(fromCallback)—— 下轮处理
|
||
*
|
||
* 设计要点:
|
||
* - 使用 XState v5 `setup({...}).createMachine({...})` 声明式 API
|
||
* - HTTP 操作用 `fromPromise` actor(一次性 Promise)
|
||
* - 保持事件类型名与原 Dart 一致,便于业务层迁移对照
|
||
* - actions 全部 inline 在 setup() 中(确保类型推断正确)
|
||
*
|
||
* **鉴权解耦**:
|
||
* chat 机器**不**感知鉴权状态 —— `isGuest` 字段已删(由 chat-screen 派生自 auth.loginStatus)。
|
||
* WebSocket 管理也由 chat-screen 监听 auth 状态机后驱动。
|
||
*
|
||
* **配额(**仅**游客)**:
|
||
* - `guestRemainingQuota` / `guestTotalQuota` **仅**游客有意义
|
||
* - 非游客"无消息限制"(业务事实)—— 永不被赋值
|
||
* - chat-screen 派 `ChatInit` 时(**只**在 guest)→ onDone 条件赋
|
||
* - `appendUserMessage` 减 `Math.max(0, q - 1)` —— 非游客 0-1=0 天然 no-op
|
||
*/
|
||
import { setup, fromPromise, assign } from "xstate";
|
||
|
||
import type { UiMessage } from "@/models/chat/ui-message";
|
||
import { chatRepository } from "@/data/repositories/chat_repository";
|
||
import type { IChatRepository } from "@/data/repositories/interfaces";
|
||
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";
|
||
|
||
import { ChatState, initialState } from "./chat-state";
|
||
import type { ChatEvent } from "./chat-events";
|
||
|
||
/** 游客配额阈值(与 Dart `GuestChatQuota` 保持一致) */
|
||
export const GuestChatQuota = {
|
||
warningThreshold: 5,
|
||
quotaExhausted: 0,
|
||
} as const;
|
||
|
||
// 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变
|
||
export type { ChatState } from "./chat-state";
|
||
export { initialState } from "./chat-state";
|
||
export type { ChatEvent } from "./chat-events";
|
||
|
||
// ============================================================
|
||
// 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;
|
||
}
|
||
|
||
// 仓库以接口类型注入:调用面只看接口,运行时仍是同一单例
|
||
const chatRepo: IChatRepository = chatRepository;
|
||
|
||
interface InitResult {
|
||
localMessages: UiMessage[];
|
||
isGuest: boolean;
|
||
guestRemainingQuota: number;
|
||
guestTotalQuota: number;
|
||
}
|
||
|
||
async function readInitData(): Promise<InitResult> {
|
||
const chatStorage = ChatStorage.getInstance();
|
||
// isGuest 在 chat 业务层仅用于"是否加载本地历史" + "onDone 条件赋配额" —— **不**是鉴权信号
|
||
// 鉴权判断已上提到 chat-screen 派生
|
||
const isGuest = !AuthStorage.getInstance().hasLoginToken();
|
||
|
||
const [dailyResult, totalResult, localResult] = await Promise.all([
|
||
chatStorage.getGuestDailyChatQuota(),
|
||
chatStorage.getGuestTotalQuota(),
|
||
isGuest ? chatRepo.getLocalMessages() : Promise.resolve(null),
|
||
]);
|
||
|
||
return {
|
||
isGuest,
|
||
// fallback 0 —— 非游客路径用不到;onDone 会**条件**赋
|
||
guestRemainingQuota: mapQuotaResult(
|
||
dailyResult as Result<{ remaining: number } | null>,
|
||
0,
|
||
),
|
||
guestTotalQuota: mapTotalQuotaResult(
|
||
totalResult as Result<number | null>,
|
||
0,
|
||
),
|
||
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 chatRepo.sendMessage(input.content);
|
||
if (Result.isErr(result)) throw result.error;
|
||
|
||
// 拉取最新本地历史
|
||
const local = await chatRepo.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 chatRepo.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 ChatState,
|
||
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,
|
||
// 游客减配额;非游客 0 - 1 = max(0, -1) = 0 —— **天然 no-op**(业务事实"非游客无限制")
|
||
guestRemainingQuota: Math.max(0, context.guestRemainingQuota - 1),
|
||
};
|
||
}),
|
||
|
||
appendUserImage: assign(({ context, event }) => {
|
||
if (event.type !== "ChatSendImage") return {};
|
||
return {
|
||
messages: [
|
||
...context.messages,
|
||
{
|
||
content: "[Image]",
|
||
isFromAI: false,
|
||
date: formatDate(),
|
||
imageUrl: event.imageBase64,
|
||
},
|
||
],
|
||
isReplyingAI: true,
|
||
// 游客减配额;非游客 no-op
|
||
guestRemainingQuota: Math.max(0, context.guestRemainingQuota - 1),
|
||
};
|
||
}),
|
||
|
||
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 };
|
||
}),
|
||
|
||
incrementQuotaExceeded: assign(({ context }) => ({
|
||
quotaExceededTrigger: context.quotaExceededTrigger + 1,
|
||
})),
|
||
},
|
||
}).createMachine({
|
||
id: "chat",
|
||
initial: "idle",
|
||
context: initialState,
|
||
states: {
|
||
idle: {
|
||
on: {
|
||
ChatInit: "initializing",
|
||
ChatScreenVisible: "ready",
|
||
ChatLoadMoreHistory: "loadingMoreHistory",
|
||
ChatSendMessage: {
|
||
guard: ({ event }) => event.content.trim().length > 0,
|
||
actions: "appendUserMessage",
|
||
},
|
||
ChatSendImage: {
|
||
actions: "appendUserImage",
|
||
},
|
||
ChatAISentenceReceived: {
|
||
actions: "appendOrUpdateAISentence",
|
||
},
|
||
ChatWebSocketError: {
|
||
actions: "appendSocketErrorMessage",
|
||
},
|
||
ChatQuotaExceeded: {
|
||
actions: "incrementQuotaExceeded",
|
||
},
|
||
ChatWebSocketConnected: {},
|
||
},
|
||
},
|
||
|
||
initializing: {
|
||
invoke: {
|
||
src: "chatInit",
|
||
onDone: {
|
||
target: "ready",
|
||
// **条件赋**:仅 isGuest 时初始化配额(业务事实"非游客无消息限制")
|
||
// - 游客:messages + quota 都更新
|
||
// - 非游客:仅 messages(local 空),quota 保持 default 0(不更新其值)
|
||
actions: assign(({ event }) => {
|
||
const updates: Partial<ChatState> = {
|
||
messages: event.output.localMessages,
|
||
};
|
||
if (event.output.isGuest) {
|
||
updates.guestRemainingQuota = event.output.guestRemainingQuota;
|
||
updates.guestTotalQuota = event.output.guestTotalQuota;
|
||
}
|
||
return updates;
|
||
}),
|
||
},
|
||
onError: {
|
||
target: "ready",
|
||
},
|
||
},
|
||
},
|
||
|
||
ready: {
|
||
on: {
|
||
ChatSendMessage: {
|
||
guard: ({ event }) => event.content.trim().length > 0,
|
||
actions: "appendUserMessage",
|
||
},
|
||
ChatSendImage: {
|
||
actions: "appendUserImage",
|
||
},
|
||
ChatLoadMoreHistory: "loadingMoreHistory",
|
||
ChatScreenInvisible: "idle",
|
||
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;
|