Files
cozsweet-frontend-nextjs/src/stores/chat/chat-machine.actors.ts
T
admin 0a9abc2502 fix(auth): stop parsing logout response body (avoids ZodError on null data)
Root cause: backend `/auth/logout` returns `{ success: true, data: null }`
(no business payload for a fire-and-forget endpoint). The previous
`AuthApi.logout` ran `unwrap(env)` (which passes null through, since
null is defined) and then `LogoutResponse.fromJson(null)`, which
Zod-parses as `z.object(...)` and throws "Invalid input: expected
object, received null".

The error was caught in `AuthRepository.logout` with
`console.warn(... clearing local anyway)` — logout actually still
worked (local clear ran), but a noisy red ZodError hit the console on
every logout click.

Fix: align `AuthApi.logout` with the existing fire-and-forget pattern
used by `register` and `sendCode` — call `httpClient` and ignore the
response body. Drop the now-unused `LogoutResponse` import.

`AuthRepository.logout` already discards the return value, so the
`Promise<LogoutResponse> → Promise<void>` signature change is safe.

Bundled in this commit (unrelated):
- chat-machine.actors.ts / user-machine.ts: removed stale design-doc
  comment blocks (IDE/linter cleanup)
- user-machine.actors.ts: removed redundant `userStorage.clearUserData()`
  from `userLogoutActor` — `authRepo.logout` already clears local
  user data, so this was a no-op duplicate.
- sidebar-screen.tsx: removed one stale comment line.
2026-06-16 18:34:55 +08:00

312 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Chat 状态机:4 个 XState actor
*/
import { fromPromise, fromCallback } from "xstate";
import { createChatWebSocket, ChatWebSocket } from "@/core/net/chat-websocket";
import { Result } from "@/utils/result";
import {
PAGE_SIZE,
chatRepo,
localMessagesToUi,
readAndSyncHistory,
readGuestQuota,
sendResponseToUiMessage,
} from "./chat-machine.helpers";
import type { ChatEvent } from "./chat-events";
// ============================================================
// 共享 WS 引用(chatWebSocketActor ↔ sendMessageWsActor
// ============================================================
/**
* 活跃 ChatWebSocket 单例引用(module-level)。
* - `chatWebSocketActor` 在 connect 后写入、cleanup 时清空
* - `sendMessageWsActor` 读取并调 `ws.sendMessage(content)`
*
* 为什么用 module-level 而非 context.wsRef
* 1) `ChatWebSocket` 是带回调 + 重连 timer 的非可序列化对象,放进
* XState context 会污染快照、警告 non-serializable
* 2) userSession 同一时间只可能有一个活跃 WSparent-level invoke),
* module 单例的语义和"userSession 内一个活跃连接"一一对应
* 3) 不需要重构 ChatWebSocket 类的对外 API
*/
let activeChatWebSocket: ChatWebSocket | null = null;
/** 暴露给 `sendMessageWsActor` 读取(测试也可 import */
export function getActiveChatWebSocket(): ChatWebSocket | null {
return activeChatWebSocket;
}
// ============================================================
// Init 任务 1:拉游客配额(fromPromise,纯 local
// ============================================================
/**
* 拉游客日配 + 总配(仅 guestSession 期间 invoke
* - 失败返 `{ remaining: 0, total: 0 }`(不抛,让 UI 还是能进 ready)
* - onDone 在 state machine 里赋 guestRemainingQuota + guestTotalQuota + 设 quotaLoaded: true
*/
export const loadQuotaActor = fromPromise<{
remaining: number;
total: number;
}>(async () => {
console.log("[chat-machine] loadQuotaActor ENTRY");
const result = await readGuestQuota();
console.log("[chat-machine] loadQuotaActor DONE", result);
return result;
});
// ============================================================
// Init 任务 2:拉 history 3 步流(fromPromise,返回最终结果)
// ============================================================
/**
* local → network → save network to local 3 步流(返回 network 最终 messages
* - 选方案 AfromPromise):UI 不会看到 local 中间态,直接显示 network 结果
* - 内部 3 步走 helper `readAndSyncHistory`(日志全在 helper 里)
* - 失败返 local(退而求其次)
*/
export const loadHistoryActor = fromPromise<{
messages: UiMessage[];
hasMore: boolean;
newOffset: number;
localOverwritten: boolean;
localCount: number;
networkCount: number;
}>(async () => {
console.log("[chat-machine] loadHistoryActor ENTRY");
const result = await readAndSyncHistory();
console.log("[chat-machine] loadHistoryActor DONE", {
finalCount: result.messages.length,
localCount: result.localCount,
networkCount: result.networkCount,
hasMore: result.hasMore,
localOverwritten: result.localOverwritten,
});
return result;
});
// ============================================================
// HTTP: one-shot Promise
// ============================================================
/**
* HTTP 发送消息(一次发送 = 一次返回)
* - 后端响应就是 AI 回复(`ChatSendResponse.reply: string`
* - 不再调 `getLocalMessages()`(多此一举)
* - 返回 `{ reply: UiMessage }` —— `sending.onDone` 把它追加到 `context.messages`
*/
export const sendMessageHttpActor = fromPromise<
{ reply: 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.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;
}
// 一次发送 = 一次返回 —— 直接转 reply → UiMessage
console.log("[chat-machine] sendMessageHttpActor converting reply to UiMessage", {
replyLength: result.data.reply.length,
replyPreview: result.data.reply.slice(0, 50),
messageId: result.data.messageId,
timestamp: result.data.timestamp,
});
const reply = sendResponseToUiMessage(result.data);
console.log("[chat-machine] sendMessageHttpActor done", {
replyContentLength: reply.content.length,
});
return { reply };
});
/** 翻历史(pagination)—— 不走 local-first,纯 server fetch */
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 }) => {
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,
});
// 写入 module-level 共享引用 —— sendMessageWsActor 借此调 ws.sendMessage()
activeChatWebSocket = ws;
console.log("[chat-machine] chatWebSocketActor activeChatWebSocket SET", {
isConnected: ws.isConnected,
});
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)",
);
// 清空 module-level 引用 —— 严格用 `=== ws` 判等,避免覆盖更新的实例
if (activeChatWebSocket === ws) {
activeChatWebSocket = null;
console.log("[chat-machine] chatWebSocketActor activeChatWebSocket CLEARED");
}
ws.disconnect();
};
},
);
// ============================================================
// WebSocket: per-send callback
// ============================================================
/**
* WS 发送消息(userSession.sendingViaWs 期间 invoke
* - 走 `activeChatWebSocket.sendMessage(content)`
* - 成功后 AI 回复通过 `chatWebSocketActor` 的 `onSentence` → `ChatAISentenceReceived` 流回
* - 失败抛错 → state machine 的 `onError` 兜底(清 isReplyingAI、回 ready
*
* 与 sendMessageHttpActor 的区别:
* - sendMessageHttpActor: 一次发送 = 一次返回(HTTP response = AI reply
* - sendMessageWsActor: 一次发送 = 0 次直接返回(WS 异步流式回 replyactor 本身不返 reply
*
* 选 fromPromise 而非 fromCallback
* - 本 actor 是 fire-and-forget —— sync 内同步执行 ws.sendMessage,立即 resolve
* - actor 本身没有"完成"语义(done 来自 WS 事件流,不来自这个 actor)
* - 抛错走 onError 通道
* - fromPromise 的 input type 推断比 fromCallback<void, ...> 稳定(XState v5 已知坑)
*/
export const sendMessageWsActor = fromPromise<void, { content: string }>(
async ({ input }) => {
console.log("[chat-machine] sendMessageWsActor ENTRY", {
contentLength: input.content.length,
contentPreview: input.content.slice(0, 50),
});
const ws = getActiveChatWebSocket();
if (!ws) {
console.error("[chat-machine] sendMessageWsActor no activeChatWebSocket");
throw new Error("[chat-machine] sendMessageWs: no active WebSocket (actor not running)");
}
console.log("[chat-machine] sendMessageWsActor calling ws.sendMessage", {
isConnected: ws.isConnected,
});
const ok = ws.sendMessage(input.content);
if (!ok) {
console.error(
"[chat-machine] sendMessageWsActor ws.sendMessage returned false (not OPEN)",
);
throw new Error("[chat-machine] sendMessageWs: WebSocket not OPEN");
}
console.log(
"[chat-machine] sendMessageWsActor ws.sendMessage OK, awaiting AI sentence stream",
);
// resolve 不返值 —— AI reply 由 chatWebSocketActor 的 ChatAISentenceReceived 流回
},
);
// Re-export `UiMessage` type for the chat-machine.ts public API
type UiMessage = import("@/models/chat/ui-message").UiMessage;