refactor: migrate img tags to next/image and fix hook usage
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* 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() 中(确保类型推断正确)
|
||||
*/
|
||||
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";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 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,
|
||||
};
|
||||
}),
|
||||
|
||||
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: initialState,
|
||||
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;
|
||||
Reference in New Issue
Block a user