0a9abc2502
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.
312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
/**
|
||
* 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 同一时间只可能有一个活跃 WS(parent-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)
|
||
* - 选方案 A(fromPromise):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 异步流式回 reply,actor 本身不返 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;
|