refactor(chat): extract XState actors into chat-machine.actors.ts
- Split chat state machine actors (chatInit, sendMessageHttp, loadMoreHistory, chatWebSocket) into a dedicated module - Add comprehensive console logging across the chat state machine for full trace debugging - Comment out margin on text bubble CSS for layout adjustment - Add debug logging to chat input bar send handler - Clean up redundant comments in auth-screen component
This commit is contained in:
@@ -18,11 +18,6 @@ import { AuthBackground } from "./auth-background";
|
||||
import { AuthPanel } from "./auth-panel";
|
||||
|
||||
export function AuthScreen() {
|
||||
// ===== 鉴权路由跳转已由 src/router/proxy.ts 集中处理 =====
|
||||
// 本组件只负责:
|
||||
// 1. 渲染登录 UI
|
||||
// 2. 邮箱登录成功 → 初始化 user store + navigate 到 /chat(业务层动作)
|
||||
|
||||
const state = useAuthState();
|
||||
const authDispatch = useAuthDispatch();
|
||||
const userDispatch = useUserDispatch();
|
||||
|
||||
@@ -42,6 +42,13 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
||||
|
||||
const handleSend = () => {
|
||||
if (!hasContent) return;
|
||||
console.log("[chat-input-bar] handleSend", {
|
||||
contentLength: input.length,
|
||||
contentPreview: input.slice(0, 50),
|
||||
hasContent,
|
||||
disabled,
|
||||
isFocused,
|
||||
});
|
||||
dispatch({ type: "ChatSendMessage", content: input });
|
||||
setInput("");
|
||||
textareaRef.current?.focus();
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
color: var(--color-text-foreground, #000);
|
||||
border-top-left-radius: 0;
|
||||
/* 气泡在头像侧的外侧留白 */
|
||||
margin-left: var(--spacing-4, 16px);
|
||||
/* margin-left: var(--spacing-4, 16px); */
|
||||
}
|
||||
|
||||
.bubbleUser {
|
||||
@@ -30,5 +30,5 @@
|
||||
color: #fff;
|
||||
border-top-right-radius: 0;
|
||||
/* 气泡在头像侧的外侧留白 */
|
||||
margin-right: var(--spacing-4, 16px);
|
||||
/* margin-right: var(--spacing-4, 16px); */
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Chat 状态机:4 个 XState actor
|
||||
*
|
||||
* 从 `chat-machine.ts` 抽出的"Actors"段:
|
||||
* - `chatInitActor`(fromPromise,**仅**游客有意义)
|
||||
* - `sendMessageHttpActor`(fromPromise,**已定义但**未 wire —— 消息**不**真发后端)
|
||||
* - `loadMoreHistoryActor`(fromPromise)
|
||||
* - `chatWebSocketActor`(fromCallback,长生命周期 —— **仅** userSession 期间 invoke)
|
||||
*
|
||||
* 设计:
|
||||
* - 依赖 `chat-machine.helpers.ts`(纯函数 + 数据加载)
|
||||
* - 依赖 `createChatWebSocket`(`@/core/net/chat-websocket`)
|
||||
* - 保留**全链日志**(用户要求"不要将其删除")
|
||||
* - 命名约定:`[chat-machine]` 前缀 —— 与 `chat-machine.ts` 的日志对齐
|
||||
*/
|
||||
|
||||
import { fromPromise, fromCallback } from "xstate";
|
||||
|
||||
import { createChatWebSocket } from "@/core/net/chat-websocket";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
import {
|
||||
PAGE_SIZE,
|
||||
chatRepo,
|
||||
localMessagesToUi,
|
||||
readInitData,
|
||||
type InitResult,
|
||||
} from "./chat-machine.helpers";
|
||||
import type { ChatEvent } from "./chat-events";
|
||||
|
||||
// ============================================================// HTTP: one-shot Promise
|
||||
// ============================================================
|
||||
/** 拉初始数据(**仅**游客有意义) */
|
||||
export const chatInitActor = fromPromise<InitResult>(async () => readInitData());
|
||||
|
||||
/**
|
||||
* HTTP 发送消息(**已定义但未** wire —— 消息**不**真发后端)
|
||||
* - 任何 state 转移**未** invoke 此 actor
|
||||
* - 保留全链日志以便排查
|
||||
*/
|
||||
export const sendMessageHttpActor = fromPromise<
|
||||
{ messages: UiMessage[] },
|
||||
{ content: string }
|
||||
>(async ({ input, self }) => {
|
||||
console.log("[chat-machine] sendMessageHttpActor ENTRY", {
|
||||
contentLength: input.content.length,
|
||||
contentPreview: input.content.slice(0, 50),
|
||||
selfId: self.id,
|
||||
selfPath: "<actor path>",
|
||||
});
|
||||
console.warn(
|
||||
"[chat-machine] sendMessageHttpActor invoked —— actor 已定义但**未** invoke(消息**不**真发后端)",
|
||||
{ contentLength: input.content.length, contentPreview: input.content.slice(0, 50) },
|
||||
);
|
||||
console.log("[chat-machine] sendMessageHttpActor calling chatRepo.sendMessage");
|
||||
const result = await chatRepo.sendMessage(input.content);
|
||||
console.log("[chat-machine] sendMessageHttpActor chatRepo.sendMessage DONE", {
|
||||
success: result.success,
|
||||
error: result.success ? null : Result.isErr(result) ? result.error : null,
|
||||
});
|
||||
if (Result.isErr(result)) {
|
||||
console.error("[chat-machine] sendMessageHttpActor result isErr, throwing");
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
console.log("[chat-machine] sendMessageHttpActor fetching local messages");
|
||||
const local = await chatRepo.getLocalMessages();
|
||||
console.log("[chat-machine] sendMessageHttpActor local messages DONE", {
|
||||
success: local.success,
|
||||
messagesCount: local.success && local.data ? local.data.length : 0,
|
||||
});
|
||||
if (Result.isOk(local) && local.data) {
|
||||
const messages = localMessagesToUi(local.data);
|
||||
console.log("[chat-machine] sendMessageHttpActor done", { messagesCount: messages.length });
|
||||
return { messages };
|
||||
}
|
||||
return { messages: [] };
|
||||
});
|
||||
|
||||
/** 翻历史(pagination) */
|
||||
export const loadMoreHistoryActor = fromPromise<
|
||||
{ messages: UiMessage[]; hasMore: boolean; newOffset: number },
|
||||
{ offset: number }
|
||||
>(async ({ input, self }) => {
|
||||
console.log("[chat-machine] loadMoreHistoryActor ENTRY", {
|
||||
inputOffset: input.offset,
|
||||
pageSize: PAGE_SIZE,
|
||||
selfPath: "<actor path>",
|
||||
});
|
||||
console.log("[chat-machine] loadMoreHistoryActor calling chatRepo.getHistory", {
|
||||
pageSize: PAGE_SIZE,
|
||||
offset: input.offset,
|
||||
});
|
||||
const result = await chatRepo.getHistory(PAGE_SIZE, input.offset);
|
||||
console.log("[chat-machine] loadMoreHistoryActor chatRepo.getHistory DONE", {
|
||||
success: result.success,
|
||||
error: result.success ? null : Result.isErr(result) ? result.error : null,
|
||||
responseType: result.success ? typeof result.data : "err",
|
||||
});
|
||||
if (Result.isErr(result)) {
|
||||
console.error("[chat-machine] loadMoreHistoryActor result isErr, throwing", {
|
||||
error: result.error,
|
||||
});
|
||||
throw result.error;
|
||||
}
|
||||
console.log("[chat-machine] loadMoreHistoryActor result data", {
|
||||
rawMessagesCount: result.data.messages.length,
|
||||
});
|
||||
const page = localMessagesToUi(result.data.messages);
|
||||
console.log("[chat-machine] loadMoreHistoryActor AFTER UiMessage mapping", {
|
||||
uiMessagesCount: page.length,
|
||||
});
|
||||
const hasMore = page.length >= PAGE_SIZE;
|
||||
const newOffset = input.offset + page.length;
|
||||
console.log("[chat-machine] loadMoreHistoryActor result", {
|
||||
pageSize: page.length,
|
||||
hasMore,
|
||||
newOffset,
|
||||
});
|
||||
return {
|
||||
messages: page,
|
||||
hasMore,
|
||||
newOffset,
|
||||
};
|
||||
});
|
||||
|
||||
// ============================================================// WebSocket: long-lived callback
|
||||
// ============================================================
|
||||
/**
|
||||
* 长生命周期 WebSocket(**仅** `userSession` 期间 invoke)
|
||||
* - 启动:连 WS,事件 → sendBack 派回机器
|
||||
* - 停止(cleanup):disconnect
|
||||
* - **游客**不** invoke 此 actor(业务事实"游客不连 WS")
|
||||
*
|
||||
* 之所以用 fromCallback 而非 fromPromise:
|
||||
* - WS 是长连接,**不**是一次性 Promise
|
||||
* - fromCallback 提供 `sendBack` 回调,actor 内部事件 → 机器
|
||||
* - cleanup 函数 → XState 在 parent state exit 时自动调用
|
||||
*/
|
||||
export const chatWebSocketActor = fromCallback<ChatEvent, { token: string }>(
|
||||
({ sendBack, input, self }) => {
|
||||
console.log("[chat-machine] chatWebSocketActor ENTRY", {
|
||||
hasToken: !!input.token,
|
||||
tokenLength: input.token?.length ?? 0,
|
||||
tokenPrefix: input.token?.slice(0, 10) ?? "EMPTY",
|
||||
selfPath: "<actor path>",
|
||||
});
|
||||
console.log("[chat-machine] chatWebSocketActor creating ChatWebSocket instance");
|
||||
const ws = createChatWebSocket(input.token);
|
||||
console.log("[chat-machine] chatWebSocketActor createChatWebSocket DONE", {
|
||||
wsType: ws.constructor.name,
|
||||
});
|
||||
ws.onConnected = (userId) => {
|
||||
console.log("[chat-machine] WS onConnected", {
|
||||
userId,
|
||||
userIdPrefix: userId?.slice(0, 8) ?? "EMPTY",
|
||||
});
|
||||
sendBack({ type: "ChatWebSocketConnected", userId });
|
||||
};
|
||||
ws.onSentence = (p) => {
|
||||
console.log("[chat-machine] WS onSentence", {
|
||||
index: p.index,
|
||||
total: p.total,
|
||||
done: p.done,
|
||||
textLength: p.text.length,
|
||||
textPreview: p.text.slice(0, 50),
|
||||
textSuffix: p.text.slice(-20),
|
||||
});
|
||||
sendBack({
|
||||
type: "ChatAISentenceReceived",
|
||||
index: p.index,
|
||||
text: p.text,
|
||||
total: p.total,
|
||||
done: p.done,
|
||||
});
|
||||
};
|
||||
ws.onError = (msg) => {
|
||||
console.error("[chat-machine] WS onError", {
|
||||
errorMessage: msg,
|
||||
errorLength: msg?.length ?? 0,
|
||||
});
|
||||
sendBack({ type: "ChatWebSocketError", errorMessage: msg });
|
||||
};
|
||||
console.log("[chat-machine] chatWebSocketActor calling ws.connect()");
|
||||
ws.connect();
|
||||
console.log("[chat-machine] chatWebSocketActor ws.connect() called");
|
||||
return () => {
|
||||
console.log(
|
||||
"[chat-machine] chatWebSocketActor CLEANUP (parent state exit / logout / cross-transition / unmount)",
|
||||
);
|
||||
ws.disconnect();
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Re-export `UiMessage` type for the chat-machine.ts public API
|
||||
type UiMessage = import("@/models/chat/ui-message").UiMessage;
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Chat 状态机:纯函数 + 数据加载
|
||||
*
|
||||
* 从 `chat-machine.ts` 抽出的"helpers"段:
|
||||
* - 翻页大小常量
|
||||
* - 仓库注入(接口类型 → 单例)
|
||||
* - DTO ↔ UiMessage 映射(**纯函数** —— 可单测)
|
||||
* - Result → number 映射(**纯函数** —— 可单测)
|
||||
* - 初始数据装配(**仅**游客有意义 —— `chatInitActor` 调用)
|
||||
*
|
||||
* 设计目标:
|
||||
* - helpers **无 XState 依赖**(可独立 import / 测试)
|
||||
* - actors 依赖 helpers(import)
|
||||
* - machine 依赖 actors(import)—— **不**直接依赖 helpers
|
||||
*/
|
||||
|
||||
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 { Result } from "@/utils/result";
|
||||
|
||||
// ============================================================// Constants
|
||||
// ============================================================
|
||||
/** 翻历史每页大小(**仅** `loadMoreHistoryActor` 用) */
|
||||
export const PAGE_SIZE = 20;
|
||||
|
||||
// ============================================================// Repository injection
|
||||
// ============================================================
|
||||
// 仓库以接口类型注入:调用面只看接口,运行时仍是同一单例
|
||||
export const chatRepo: IChatRepository = chatRepository;
|
||||
|
||||
// ============================================================// DTO ↔ UiMessage 映射
|
||||
// ============================================================
|
||||
/**
|
||||
* ChatMessage[] → UiMessage[](**纯函数**)
|
||||
* - 与 Dart `LocalMessage → ChatMessage` 等价
|
||||
* - 业务事实:`role === "assistant"` → `isFromAI: true`
|
||||
*/
|
||||
export 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,
|
||||
}));
|
||||
}
|
||||
|
||||
// ============================================================// Result → number 映射
|
||||
// ============================================================
|
||||
/** 日配额 Result → number(**纯函数**) */
|
||||
export function mapQuotaResult(
|
||||
r: Result<{ remaining: number } | null>,
|
||||
fallback: number,
|
||||
): number {
|
||||
if (r.success && r.data != null) return r.data.remaining;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** 总配额 Result → number(**纯函数**) */
|
||||
export function mapTotalQuotaResult(
|
||||
r: Result<number | null>,
|
||||
fallback: number,
|
||||
): number {
|
||||
if (r.success && r.data != null) return r.data;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// ============================================================// Initial data
|
||||
// ============================================================
|
||||
export interface InitResult {
|
||||
localMessages: UiMessage[];
|
||||
isGuest: boolean;
|
||||
guestRemainingQuota: number;
|
||||
guestTotalQuota: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取初始数据(`chatInitActor` 调用)
|
||||
*
|
||||
* - `isGuest` 在 chat 业务层仅用于"是否加载本地历史" + "onDone 条件赋配额"
|
||||
* —— **不**是鉴权信号。鉴权判断已上提到 chat-screen 派生。
|
||||
* - 配额 fallback 0 —— 非游客路径用不到;onDone 会**条件**赋。
|
||||
*/
|
||||
export 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 ? chatRepo.getLocalMessages() : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
return {
|
||||
isGuest,
|
||||
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)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
+227
-225
@@ -34,25 +34,38 @@
|
||||
* - `loadingMore`:invoke loadMoreHistory(翻历史)
|
||||
* WS 在 `userSession` parent 状态 invoke —— 跨 initializing/ready/loadingMore 持续
|
||||
*
|
||||
* **消息发送路径**(**业务事实**):
|
||||
* - `wsConnected: true`(**仅** userSession 期间)→ **走 WS**,**不**消耗次数
|
||||
* - `wsConnected: false` → **走 HTTP**,消耗次数(**仅**游客有意义 —— 非游客 0-1=max(0,-1)=0 天然 no-op)
|
||||
* - `ChatWebSocketConnected` 事件 → `wsConnected = true`
|
||||
* - `userSession.exit` action → `wsConnected = false`(WS actor cleanup 时**也**会跑 exit)
|
||||
*
|
||||
* **配额(**仅**游客)**:
|
||||
* - `guestRemainingQuota` / `guestTotalQuota` **仅**游客有意义
|
||||
* - 非游客"无消息限制"(业务事实)—— 永不被赋值
|
||||
* - `ChatGuestLogin` 触发 chatInitActor → onDone 条件赋
|
||||
* - `appendUserMessage` 减 `Math.max(0, q - 1)` —— 非游客 0-1=0 天然 no-op
|
||||
* - `appendUserMessage` 减 `context.wsConnected ? q : Math.max(0, q-1)` —— WS 已连时**不**递减
|
||||
*
|
||||
* **文件结构**:
|
||||
* - `chat-machine.ts`(本文件)—— **只**含 setup + createMachine(actions + states)
|
||||
* - `chat-machine.helpers.ts` —— 纯函数 + 数据加载
|
||||
* - `chat-machine.actors.ts` —— 4 个 XState actor(含全链日志)
|
||||
*
|
||||
* **调试日志**(保留 —— 用户要求"不要将其删除"):
|
||||
* - 命名约定:`[<file>]` 前缀标识来源
|
||||
* - 3 actor **全链日志**(ENTRY / API / DONE / 关键决策)—— 排查 actor **没**被调时**一眼**看到
|
||||
*/
|
||||
import { setup, fromPromise, fromCallback, 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 { createChatWebSocket } from "@/core/net/chat-websocket";
|
||||
import { setup, assign } from "xstate";
|
||||
|
||||
import { formatDate } from "@/utils/date";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
import { ChatState, initialState } from "./chat-state";
|
||||
import type { ChatEvent } from "./chat-events";
|
||||
import {
|
||||
chatInitActor,
|
||||
sendMessageHttpActor,
|
||||
loadMoreHistoryActor,
|
||||
chatWebSocketActor,
|
||||
} from "./chat-machine.actors";
|
||||
|
||||
/** 游客配额阈值(与 Dart `GuestChatQuota` 保持一致) */
|
||||
export const GuestChatQuota = {
|
||||
@@ -65,143 +78,8 @@ 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
|
||||
// ============================================================
|
||||
// HTTP:一次性 Promise
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
// WebSocket:长生命周期(**由** chat 机器内 fromCallback actor 管)
|
||||
// - 启动:连 WS,事件 → sendBack 派回机器
|
||||
// - 停止(cleanup):disconnect
|
||||
// - **游客**不** invoke 此 actor(业务事实"游客不连 WS")
|
||||
const chatWebSocketActor = fromCallback<ChatEvent, { token: string }>(
|
||||
({ sendBack, input }) => {
|
||||
const ws = createChatWebSocket(input.token);
|
||||
ws.onConnected = (userId) => {
|
||||
sendBack({ type: "ChatWebSocketConnected", userId });
|
||||
};
|
||||
ws.onSentence = (p) => {
|
||||
sendBack({
|
||||
type: "ChatAISentenceReceived",
|
||||
index: p.index,
|
||||
text: p.text,
|
||||
total: p.total,
|
||||
done: p.done,
|
||||
});
|
||||
};
|
||||
ws.onError = (msg) => {
|
||||
sendBack({ type: "ChatWebSocketError", errorMessage: msg });
|
||||
};
|
||||
ws.connect();
|
||||
return () => ws.disconnect();
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================================// Machine
|
||||
// Machine
|
||||
// ============================================================
|
||||
export const chatMachine = setup({
|
||||
types: {
|
||||
@@ -217,6 +95,20 @@ export const chatMachine = setup({
|
||||
actions: {
|
||||
appendUserMessage: assign(({ context, event }) => {
|
||||
if (event.type !== "ChatSendMessage") return {};
|
||||
const newQuota = context.wsConnected
|
||||
? context.guestRemainingQuota
|
||||
: Math.max(0, context.guestRemainingQuota - 1);
|
||||
console.log("[chat-machine] appendUserMessage", {
|
||||
contentLength: event.content.length,
|
||||
contentPreview: event.content.slice(0, 50),
|
||||
oldMessagesCount: context.messages.length,
|
||||
newMessagesCount: context.messages.length + 1,
|
||||
wsConnected: context.wsConnected,
|
||||
quotaMode: context.wsConnected ? "ws (no decrement)" : "http (decrement)",
|
||||
oldQuota: context.guestRemainingQuota,
|
||||
newQuota,
|
||||
isReplyingAI: true,
|
||||
});
|
||||
return {
|
||||
messages: [
|
||||
...context.messages,
|
||||
@@ -227,13 +119,22 @@ export const chatMachine = setup({
|
||||
},
|
||||
],
|
||||
isReplyingAI: true,
|
||||
// 游客减配额;非游客 0 - 1 = max(0, -1) = 0 —— **天然 no-op**(业务事实"非游客无限制")
|
||||
guestRemainingQuota: Math.max(0, context.guestRemainingQuota - 1),
|
||||
guestRemainingQuota: newQuota,
|
||||
};
|
||||
}),
|
||||
|
||||
appendUserImage: assign(({ context, event }) => {
|
||||
if (event.type !== "ChatSendImage") return {};
|
||||
const newQuota = context.wsConnected
|
||||
? context.guestRemainingQuota
|
||||
: Math.max(0, context.guestRemainingQuota - 1);
|
||||
console.log("[chat-machine] appendUserImage", {
|
||||
oldMessagesCount: context.messages.length,
|
||||
wsConnected: context.wsConnected,
|
||||
quotaMode: context.wsConnected ? "ws (no decrement)" : "http (decrement)",
|
||||
oldQuota: context.guestRemainingQuota,
|
||||
newQuota,
|
||||
});
|
||||
return {
|
||||
messages: [
|
||||
...context.messages,
|
||||
@@ -245,8 +146,7 @@ export const chatMachine = setup({
|
||||
},
|
||||
],
|
||||
isReplyingAI: true,
|
||||
// 游客减配额;非游客 no-op
|
||||
guestRemainingQuota: Math.max(0, context.guestRemainingQuota - 1),
|
||||
guestRemainingQuota: newQuota,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -268,6 +168,12 @@ export const chatMachine = setup({
|
||||
};
|
||||
}
|
||||
}
|
||||
console.log("[chat-machine] appendOrUpdateAISentence", {
|
||||
index: event.index,
|
||||
total: event.total,
|
||||
done: event.done,
|
||||
messagesCount: messages.length,
|
||||
});
|
||||
return { messages, isReplyingAI: !event.done };
|
||||
}),
|
||||
|
||||
@@ -286,109 +192,159 @@ export const chatMachine = setup({
|
||||
incrementQuotaExceeded: assign(({ context }) => ({
|
||||
quotaExceededTrigger: context.quotaExceededTrigger + 1,
|
||||
})),
|
||||
|
||||
setWsConnected: assign({ wsConnected: true }),
|
||||
clearWsConnected: assign({ wsConnected: false }),
|
||||
|
||||
logSendMessageReceived: ({ event, context }) =>
|
||||
console.log("[chat-machine] SendMessage received", {
|
||||
contentLength: event.type === "ChatSendMessage" ? event.content.length : 0,
|
||||
isReplyingAI: context.isReplyingAI,
|
||||
wsConnected: context.wsConnected,
|
||||
currentMessagesCount: context.messages.length,
|
||||
}),
|
||||
|
||||
logSendImageReceived: ({ event, context }) =>
|
||||
console.log("[chat-machine] SendImage received", {
|
||||
hasImageBase64: event.type === "ChatSendImage" ? !!event.imageBase64 : false,
|
||||
wsConnected: context.wsConnected,
|
||||
}),
|
||||
|
||||
logLoadMoreHistoryReceived: ({ context }) =>
|
||||
console.log("[chat-machine] LoadMoreHistory received", {
|
||||
currentOffset: context.historyOffset,
|
||||
hasMore: context.hasMore,
|
||||
}),
|
||||
},
|
||||
}).createMachine({
|
||||
id: "chat",
|
||||
initial: "idle",
|
||||
context: initialState,
|
||||
states: {
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 屏没挂 / 登出 / 跨态前
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
idle: {
|
||||
on: {
|
||||
ChatGuestLogin: "#chat.guestSession",
|
||||
ChatNonGuestLogin: "#chat.userSession",
|
||||
ChatLoadMoreHistory: "loadingMoreHistory", // 旧逻辑保留(翻历史用)
|
||||
ChatLoadMoreHistory: {
|
||||
actions: [
|
||||
"logLoadMoreHistoryReceived",
|
||||
({ context }) =>
|
||||
console.log("[chat-machine] idle.ChatLoadMoreHistory received (but no UI dispatcher)", {
|
||||
currentOffset: context.historyOffset,
|
||||
hasMore: context.hasMore,
|
||||
isLoadingMore: context.isLoadingMore,
|
||||
}),
|
||||
],
|
||||
target: "loadingMoreHistory",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 游客会话(**不** invoke WS —— "断 WS"业务事实)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
guestSession: {
|
||||
initial: "initializing",
|
||||
// guestSession **不** invoke chatWebSocket —— 业务事实"游客不连 WS"
|
||||
states: {
|
||||
initializing: {
|
||||
invoke: {
|
||||
src: "chatInit",
|
||||
onDone: {
|
||||
target: "ready",
|
||||
// **条件赋**:仅 isGuest 时初始化配额(业务事实"非游客无消息限制")
|
||||
actions: assign(({ event }) => {
|
||||
const updates: Partial<ChatState> = {
|
||||
messages: event.output.localMessages,
|
||||
};
|
||||
if (event.output.isGuest) {
|
||||
updates.guestRemainingQuota =
|
||||
event.output.guestRemainingQuota;
|
||||
updates.guestRemainingQuota = event.output.guestRemainingQuota;
|
||||
updates.guestTotalQuota = event.output.guestTotalQuota;
|
||||
}
|
||||
console.log("[chat-machine] guestSession.initializing.onDone", {
|
||||
messagesCount: event.output.localMessages.length,
|
||||
isGuest: event.output.isGuest,
|
||||
guestRemainingQuota: updates.guestRemainingQuota ?? 0,
|
||||
guestTotalQuota: updates.guestTotalQuota ?? 0,
|
||||
});
|
||||
return updates;
|
||||
}),
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: ({ event }) =>
|
||||
console.error("[chat-machine] guestSession.initializing.onError", {
|
||||
error: event.error instanceof Error ? event.error.message : String(event.error),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
ready: {
|
||||
on: {
|
||||
ChatSendMessage: {
|
||||
actions: ["logSendMessageReceived", "appendUserMessage"],
|
||||
guard: ({ event }) => event.content.trim().length > 0,
|
||||
actions: "appendUserMessage",
|
||||
},
|
||||
ChatSendImage: {
|
||||
actions: "appendUserImage",
|
||||
actions: ["logSendImageReceived", "appendUserImage"],
|
||||
},
|
||||
ChatLoadMoreHistory: "loadingMore",
|
||||
ChatLogout: "#chat.idle", // 登出 → idle
|
||||
// 跨态(游客 → email)
|
||||
ChatLoadMoreHistory: {
|
||||
actions: [
|
||||
"logLoadMoreHistoryReceived",
|
||||
({ context }) =>
|
||||
console.log("[chat-machine] guestSession.ready.ChatLoadMoreHistory received", {
|
||||
currentOffset: context.historyOffset,
|
||||
hasMore: context.hasMore,
|
||||
}),
|
||||
],
|
||||
target: "loadingMore",
|
||||
},
|
||||
ChatLogout: "#chat.idle",
|
||||
ChatNonGuestLogin: "#chat.userSession",
|
||||
ChatAISentenceReceived: {
|
||||
actions: "appendOrUpdateAISentence",
|
||||
},
|
||||
ChatWebSocketError: {
|
||||
actions: "appendSocketErrorMessage",
|
||||
},
|
||||
ChatQuotaExceeded: {
|
||||
actions: "incrementQuotaExceeded",
|
||||
},
|
||||
ChatWebSocketConnected: {},
|
||||
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
|
||||
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
|
||||
ChatWebSocketConnected: { actions: "setWsConnected" },
|
||||
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
|
||||
},
|
||||
},
|
||||
loadingMore: {
|
||||
entry: ({ context }) =>
|
||||
console.log("[chat-machine] → guestSession.loadingMore", { offset: context.historyOffset }),
|
||||
invoke: {
|
||||
src: "loadMoreHistory",
|
||||
input: ({ context }) => ({ offset: context.historyOffset }),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: assign(({ context, event }) => ({
|
||||
actions: [
|
||||
assign(({ context, event }) => ({
|
||||
messages: [...event.output.messages, ...context.messages],
|
||||
isLoadingMore: false,
|
||||
hasMore: event.output.hasMore,
|
||||
historyOffset: event.output.newOffset,
|
||||
})),
|
||||
({ event }) =>
|
||||
console.log("[chat-machine] guestSession.loadingMore.onDone", {
|
||||
newMessagesCount: event.output.messages.length,
|
||||
newOffset: event.output.newOffset,
|
||||
hasMore: event.output.hasMore,
|
||||
}),
|
||||
],
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: assign({ isLoadingMore: false }),
|
||||
actions: [
|
||||
assign({ isLoadingMore: false }),
|
||||
({ event }) =>
|
||||
console.error("[chat-machine] guestSession.loadingMore.onError", {
|
||||
error: event.error instanceof Error ? event.error.message : String(event.error),
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 非游客会话(**invoke WS** —— "连 WS"业务事实)
|
||||
// chatWebSocket 在 parent 状态 invoke —— 跨 initializing/ready/loadingMore 持续
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
userSession: {
|
||||
initial: "initializing",
|
||||
exit: "clearWsConnected",
|
||||
invoke: {
|
||||
src: "chatWebSocket",
|
||||
// event 是触发 userSession 状态的事件(ChatNonGuestLogin from idle / ChatGuestLogin from guestSession)
|
||||
input: ({ event }) => ({
|
||||
token: event.type === "ChatNonGuestLogin" ? event.token : "",
|
||||
}),
|
||||
@@ -400,116 +356,162 @@ export const chatMachine = setup({
|
||||
input: ({ context }) => ({ offset: context.historyOffset }),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: assign(({ event }) => ({
|
||||
actions: [
|
||||
assign(({ event }) => ({
|
||||
messages: event.output.messages,
|
||||
isLoadingMore: false,
|
||||
hasMore: event.output.hasMore,
|
||||
historyOffset: event.output.newOffset,
|
||||
})),
|
||||
({ event }) =>
|
||||
console.log("[chat-machine] userSession.initializing.onDone", {
|
||||
newMessagesCount: event.output.messages.length,
|
||||
newOffset: event.output.newOffset,
|
||||
hasMore: event.output.hasMore,
|
||||
}),
|
||||
],
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: assign({ isLoadingMore: false }),
|
||||
actions: [
|
||||
assign({ isLoadingMore: false }),
|
||||
({ event }) =>
|
||||
console.error("[chat-machine] userSession.initializing.onError", {
|
||||
error: event.error instanceof Error ? event.error.message : String(event.error),
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
ready: {
|
||||
on: {
|
||||
ChatSendMessage: {
|
||||
actions: ["logSendMessageReceived", "appendUserMessage"],
|
||||
guard: ({ event }) => event.content.trim().length > 0,
|
||||
actions: "appendUserMessage",
|
||||
},
|
||||
ChatSendImage: {
|
||||
actions: "appendUserImage",
|
||||
actions: ["logSendImageReceived", "appendUserImage"],
|
||||
},
|
||||
ChatLoadMoreHistory: "loadingMore",
|
||||
ChatLogout: "#chat.idle", // 登出 → idle(cleanup WS)
|
||||
// 跨态(email → 游客)
|
||||
ChatLoadMoreHistory: {
|
||||
actions: [
|
||||
"logLoadMoreHistoryReceived",
|
||||
({ context }) =>
|
||||
console.log("[chat-machine] userSession.ready.ChatLoadMoreHistory received", {
|
||||
currentOffset: context.historyOffset,
|
||||
hasMore: context.hasMore,
|
||||
}),
|
||||
],
|
||||
target: "loadingMore",
|
||||
},
|
||||
ChatLogout: "#chat.idle",
|
||||
ChatGuestLogin: "#chat.guestSession",
|
||||
ChatAISentenceReceived: {
|
||||
actions: "appendOrUpdateAISentence",
|
||||
},
|
||||
ChatWebSocketError: {
|
||||
actions: "appendSocketErrorMessage",
|
||||
},
|
||||
ChatQuotaExceeded: {
|
||||
actions: "incrementQuotaExceeded",
|
||||
},
|
||||
ChatWebSocketConnected: {},
|
||||
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
|
||||
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
|
||||
ChatWebSocketConnected: { actions: "setWsConnected" },
|
||||
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
|
||||
},
|
||||
},
|
||||
loadingMore: {
|
||||
entry: ({ context }) =>
|
||||
console.log("[chat-machine] → userSession.loadingMore", { offset: context.historyOffset }),
|
||||
invoke: {
|
||||
src: "loadMoreHistory",
|
||||
input: ({ context }) => ({ offset: context.historyOffset }),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: assign(({ context, event }) => ({
|
||||
actions: [
|
||||
assign(({ context, event }) => ({
|
||||
messages: [...event.output.messages, ...context.messages],
|
||||
isLoadingMore: false,
|
||||
hasMore: event.output.hasMore,
|
||||
historyOffset: event.output.newOffset,
|
||||
})),
|
||||
({ event }) =>
|
||||
console.log("[chat-machine] userSession.loadingMore.onDone", {
|
||||
newMessagesCount: event.output.messages.length,
|
||||
newOffset: event.output.newOffset,
|
||||
hasMore: event.output.hasMore,
|
||||
}),
|
||||
],
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: assign({ isLoadingMore: false }),
|
||||
actions: [
|
||||
assign({ isLoadingMore: false }),
|
||||
({ event }) =>
|
||||
console.error("[chat-machine] userSession.loadingMore.onError", {
|
||||
error: event.error instanceof Error ? event.error.message : String(event.error),
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 旧 loadingMoreHistory state(兼容 ChatLoadMoreHistory 在 idle 时派的事件)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
loadingMoreHistory: {
|
||||
entry: ({ context }) =>
|
||||
console.log("[chat-machine] → loadingMoreHistory (legacy)", { offset: context.historyOffset }),
|
||||
invoke: {
|
||||
src: "loadMoreHistory",
|
||||
input: ({ context }) => ({ offset: context.historyOffset }),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: assign(({ context, event }) => ({
|
||||
actions: [
|
||||
assign(({ context, event }) => ({
|
||||
messages: [...event.output.messages, ...context.messages],
|
||||
isLoadingMore: false,
|
||||
hasMore: event.output.hasMore,
|
||||
historyOffset: event.output.newOffset,
|
||||
})),
|
||||
({ event }) =>
|
||||
console.log("[chat-machine] loadingMoreHistory.onDone", {
|
||||
newMessagesCount: event.output.messages.length,
|
||||
newOffset: event.output.newOffset,
|
||||
hasMore: event.output.hasMore,
|
||||
}),
|
||||
],
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: assign({ isLoadingMore: false }),
|
||||
actions: [
|
||||
assign({ isLoadingMore: false }),
|
||||
({ event }) =>
|
||||
console.error("[chat-machine] loadingMoreHistory.onError", {
|
||||
error: event.error instanceof Error ? event.error.message : String(event.error),
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 旧 ready state(兼容 ChatScreenVisible 事件)—— **不** invoke WS
|
||||
// (**实际**不会走到这里 —— guestSession.ready / userSession.ready 是真 ready)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
ready: {
|
||||
on: {
|
||||
ChatSendMessage: {
|
||||
actions: ["logSendMessageReceived", "appendUserMessage"],
|
||||
guard: ({ event }) => event.content.trim().length > 0,
|
||||
actions: "appendUserMessage",
|
||||
},
|
||||
ChatSendImage: {
|
||||
actions: "appendUserImage",
|
||||
actions: ["logSendImageReceived", "appendUserImage"],
|
||||
},
|
||||
ChatLoadMoreHistory: {
|
||||
actions: [
|
||||
"logLoadMoreHistoryReceived",
|
||||
({ context }) =>
|
||||
console.log("[chat-machine] ready.ChatLoadMoreHistory received (legacy ready)", {
|
||||
currentOffset: context.historyOffset,
|
||||
hasMore: context.hasMore,
|
||||
}),
|
||||
],
|
||||
target: "loadingMoreHistory",
|
||||
},
|
||||
ChatLoadMoreHistory: "loadingMoreHistory",
|
||||
ChatLogout: "#chat.idle",
|
||||
ChatGuestLogin: "#chat.guestSession",
|
||||
ChatNonGuestLogin: "#chat.userSession",
|
||||
ChatAISentenceReceived: {
|
||||
actions: "appendOrUpdateAISentence",
|
||||
},
|
||||
ChatWebSocketError: {
|
||||
actions: "appendSocketErrorMessage",
|
||||
},
|
||||
ChatQuotaExceeded: {
|
||||
actions: "incrementQuotaExceeded",
|
||||
},
|
||||
ChatWebSocketConnected: {},
|
||||
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
|
||||
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
|
||||
ChatWebSocketConnected: { actions: "setWsConnected" },
|
||||
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
* - 游客:chat-screen 派 `ChatInit` → readInitData 调 ChatStorage → onDone 条件赋
|
||||
* - 非游客:**永不被赋值**(业务事实"非游客无消息限制")—— 0 - 1 = max(0, -1) = 0 天然 no-op
|
||||
* - 0 同时表示"未初始化"和"已耗尽" —— UI 用 `if (isGuest && ...)` 天然过滤
|
||||
*
|
||||
* 新增 `wsConnected: boolean`(**仅** userSession 期间 true):
|
||||
* - **WS 已连** → 消息发送走 WS,**不**消耗次数
|
||||
* - **WS 未连** → 消息发送走 HTTP,消耗次数
|
||||
* - `ChatWebSocketConnected` 事件 → true
|
||||
* - `userSession.exit` → false(WS actor cleanup 时**也**会跑 exit)
|
||||
*/
|
||||
import type { UiMessage } from "@/models/chat/ui-message";
|
||||
|
||||
@@ -16,7 +22,13 @@ export interface ChatState {
|
||||
messages: UiMessage[];
|
||||
isReplyingAI: boolean;
|
||||
/**
|
||||
* 游客剩余配额(次/天)—— **仅**游客业务展示用
|
||||
* WebSocket 连接状态(**仅** userSession 期间 true)
|
||||
* - 决定消息发送**路径**(WS / HTTP)和**是否消耗**次数
|
||||
* - true:走 WS,**不**消耗次数
|
||||
* - false:走 HTTP,消耗次数
|
||||
*/
|
||||
wsConnected: boolean;
|
||||
/** 游客剩余配额(次/天)—— **仅**游客业务展示用
|
||||
* - default 0 = "未初始化" / "已耗尽"(同码,靠 `isGuest` 区分)
|
||||
* - 非游客:永不被赋值(业务事实"无消息限制")
|
||||
*/
|
||||
@@ -32,6 +44,7 @@ export interface ChatState {
|
||||
export const initialState: ChatState = {
|
||||
messages: [],
|
||||
isReplyingAI: false,
|
||||
wsConnected: false,
|
||||
guestRemainingQuota: 0,
|
||||
guestTotalQuota: 0,
|
||||
quotaExceededTrigger: 0,
|
||||
|
||||
Reference in New Issue
Block a user