Files
cozsweet-frontend-nextjs/src/stores/chat/chat-machine.actors.ts
T

303 lines
10 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 状态机:history / send / websocket / queue actors
*/
import { fromPromise, fromCallback } from "xstate";
import { createChatWebSocket, ChatWebSocket } from "@/core/net/chat-websocket";
import { MessageQueue } from "@/core/net/message-queue";
import type { ChatSendResponse } from "@/data/dto/chat";
import { Result, Logger } from "@/utils";
import {
PAGE_SIZE,
chatRepo,
localMessagesToUi,
readAndSyncHistory,
sendResponseToUiMessage,
} from "./chat-machine.helpers";
import type { ChatEvent } from "./chat-events";
const log = new Logger("StoresChatChatMachineActors");
// ============================================================
// 共享 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) vipUserSession 同一时间只可能有一个活跃 WSparent-level invoke),
* module 单例的语义和"VIP 会话内一个活跃连接"一一对应
* 3) 不需要重构 ChatWebSocket 类的对外 API
*/
let activeChatWebSocket: ChatWebSocket | null = null;
/** 暴露给 `sendMessageWsActor` 读取(测试也可 import */
export function getActiveChatWebSocket(): ChatWebSocket | null {
return activeChatWebSocket;
}
// ============================================================
// 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 () => {
return readAndSyncHistory();
});
// ============================================================
// HTTP: one-shot Promise
// ============================================================
/**
* HTTP 发送消息(一次发送 = 一次返回)
* - 后端响应就是 AI 回复(`ChatSendResponse.reply: string`
* - 不再调 `getLocalMessages()`(多此一举)
* - 返回完整 response + 可追加的 reply,方便处理后端 lockDetail 响应
*/
export const sendMessageHttpActor = fromPromise<
{ response: ChatSendResponse; reply: UiMessage | null },
{ content: string }
>(async ({ input }) => {
return sendMessageViaHttp(input.content);
});
/** 翻历史(pagination)—— 不走 local-first,纯 server fetch */
export 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)) {
log.error("[chat-machine] loadMoreHistoryActor failed", { error: result.error });
throw result.error;
}
const page = localMessagesToUi(result.data.messages);
return {
messages: page,
hasMore: page.length >= PAGE_SIZE,
newOffset: input.offset + page.length,
};
});
export const unlockPrivateMessageActor = fromPromise<
{
messageId: string;
response: import("@/data/dto/chat").UnlockPrivateResponse;
},
{ messageId: string }
>(async ({ input }) => {
const result = await chatRepo.unlockPrivateMessage(input.messageId);
if (Result.isErr(result)) {
log.error("[chat-machine] unlockPrivateMessageActor failed", {
messageId: input.messageId,
error: result.error,
});
throw result.error;
}
if (result.data.unlocked && result.data.content != null) {
const localResult = await chatRepo.markPrivateMessageUnlockedInLocal(
input.messageId,
result.data.content,
);
if (Result.isErr(localResult)) {
log.error("[chat-machine] unlockPrivateMessageActor local sync failed", {
messageId: input.messageId,
error: localResult.error,
});
}
}
return {
messageId: input.messageId,
response: result.data,
};
});
// ============================================================
// WebSocket: long-lived callback
// ============================================================
/**
* 长生命周期 WebSocket(仅 `vipUserSession` 期间 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 }) => {
const ws = createChatWebSocket(input.token);
activeChatWebSocket = ws;
ws.onConnected = (userId) => {
log.debug("[chat-machine] WS connected", { userId });
sendBack({ type: "ChatWebSocketConnected", userId });
};
ws.onSentence = (p) => {
sendBack({
type: "ChatAISentenceReceived",
index: p.index,
text: p.text,
total: p.total,
done: p.done,
});
};
ws.onImage = (url) => {
sendBack({ type: "ChatImageReceived", url });
};
ws.onPaywallStatus = (p) => {
sendBack({
type: "ChatPaywallStatusReceived",
lockDetail: p,
});
};
ws.onError = (msg) => {
log.error("[chat-machine] WS error", { errorMessage: msg });
sendBack({ type: "ChatWebSocketError", errorMessage: msg });
};
ws.connect();
return () => {
log.debug("[chat-machine] chatWebSocketActor cleanup");
if (activeChatWebSocket === ws) {
activeChatWebSocket = null;
}
ws.disconnect();
};
},
);
// ============================================================
// WebSocket: per-send callback
// ============================================================
/**
* WS 发送消息(vipUserSession.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 }) => {
const ws = getActiveChatWebSocket();
if (!ws) {
log.error("[chat-machine] sendMessageWsActor no active WebSocket");
throw new Error("[chat-machine] sendMessageWs: no active WebSocket (actor not running)");
}
const ok = ws.sendMessage(input.content);
if (!ok) {
log.error("[chat-machine] sendMessageWsActor WebSocket not OPEN");
throw new Error("[chat-machine] sendMessageWs: WebSocket not OPEN");
}
// resolve 不返值 —— AI reply 由 chatWebSocketActor 的 ChatAISentenceReceived 流回
},
);
export const httpMessageQueueActor = fromCallback<ChatEvent>(
({ sendBack, receive }) => {
return createMessageQueueActor("http", sendBack, receive);
},
);
export const wsPreferredMessageQueueActor = fromCallback<ChatEvent>(
({ sendBack, receive }) => {
return createMessageQueueActor("wsPreferred", sendBack, receive);
},
);
function createMessageQueueActor(
mode: "http" | "wsPreferred",
sendBack: (event: ChatEvent) => void,
receive: (listener: (event: ChatEvent) => void) => void,
): () => void {
const queue = new MessageQueue();
queue.setConsumer(async (content) => {
sendBack({ type: "ChatQueuedSendStarted" });
try {
if (mode === "wsPreferred") {
const ws = getActiveChatWebSocket();
if (ws?.sendMessage(content)) return;
log.debug("[chat-machine] message queue fallback to HTTP", {
contentLength: content.length,
});
}
const output = await sendMessageViaHttp(content);
sendBack({ type: "ChatQueuedHttpDone", output });
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Message send failed";
log.error("[chat-machine] message queue send failed", {
error,
mode,
});
sendBack({
type: "ChatQueuedSendError",
content,
errorMessage,
});
}
});
receive((event) => {
if (event.type !== "ChatSendMessage") return;
const content = event.content.trim();
if (content.length === 0) return;
queue.enqueue(content);
});
return () => queue.dispose();
}
// Re-export `UiMessage` type for the chat-machine.ts public API
type UiMessage = import("@/data/dto/chat").UiMessage;
async function sendMessageViaHttp(content: string): Promise<{
response: ChatSendResponse;
reply: UiMessage | null;
}> {
const result = await chatRepo.sendMessage(content);
if (Result.isErr(result)) {
log.error("[chat-machine] sendMessageHttpActor failed", { error: result.error });
throw result.error;
}
const isDailyLimit =
result.data.lockDetail.locked &&
result.data.lockDetail.showUpgrade &&
result.data.lockDetail.reason === "daily_limit";
return {
response: result.data,
reply: isDailyLimit ? null : sendResponseToUiMessage(result.data),
};
}