699 lines
23 KiB
TypeScript
699 lines
23 KiB
TypeScript
/**
|
||
* Chat 状态机(XState v5)
|
||
*
|
||
* 鉴权解耦(事件驱动):
|
||
* chat 机器不感知鉴权 / 不管 WebSocket —— 由 <ChatAuthSync /> 派生 loginStatus
|
||
* ChatAuthSync 派发登录态生命周期事件:
|
||
* - `ChatGuestLogin` → 游客会话(断 WS = 不连)
|
||
* - `ChatNonVipLogin { token }` → 非 VIP 用户会话(不连 WS)
|
||
* - `ChatVipLogin { token }` → VIP 用户会话(连 WS)
|
||
* - `ChatLogout` → 登出(机器自动 cleanup WS actor)
|
||
*
|
||
* 状态结构(parent state 模式):
|
||
* - `idle`:屏没挂 / 登出
|
||
* - `guestSession`(parent):游客会话 —— 不 invoke WS
|
||
* - `nonVipUserSession`(parent):非 VIP 用户会话 —— 不 invoke WS
|
||
* - `vipUserSession`(parent):VIP 用户会话 —— invoke WS(持久)
|
||
*
|
||
* init 双任务(核心重构):
|
||
* - guestSession.initializing:loadQuota + loadHistory 并行(`invoke: [...]` 数组)
|
||
* - nonVipUserSession.initializing:loadHistory
|
||
* - vipUserSession.initializing:loadHistory + parent-level chatWebSocket 并行
|
||
* - 每个任务都是独立 actor,不互相等待(除非 `always` barrier)
|
||
*
|
||
* 消息发送路径(业务事实):
|
||
* - guestSession:走 HTTP,并先检查本地游客配额
|
||
* - nonVipUserSession:走 HTTP,让后端返回 daily_limit blocked
|
||
* - vipUserSession + WS 已连:走 WS,不受每日免费次数限制
|
||
* - `ChatWebSocketConnected` 事件 → `wsConnected = true`
|
||
* - `vipUserSession.exit` action → `wsConnected = false`(WS actor cleanup 时也会跑 exit)
|
||
*
|
||
* 配额:
|
||
* - 游客:保留本地配额 guard,命中后不发送消息
|
||
* - 注册非 VIP:以后端 `blocked=true && blockReason="daily_limit"` 为准
|
||
* - `appendUserMessage` 减 `context.wsConnected ? q : Math.max(0, q-1)` —— WS 已连时不递减
|
||
*
|
||
*/
|
||
|
||
import { setup, assign } from "xstate";
|
||
|
||
import { ChatStorage } from "@/data/storage/chat/chat_storage";
|
||
import { formatDate, todayString, Logger } from "@/utils";
|
||
|
||
import { ChatState, initialState } from "./chat-state";
|
||
import type { ChatEvent } from "./chat-events";
|
||
import { applyHttpSendOutput } from "./chat-machine.helpers";
|
||
import {
|
||
loadQuotaActor,
|
||
loadHistoryActor,
|
||
sendMessageHttpActor,
|
||
sendMessageWsActor,
|
||
loadMoreHistoryActor,
|
||
chatWebSocketActor,
|
||
} from "./chat-machine.actors";
|
||
|
||
const log = new Logger("StoresChatChatMachine");
|
||
|
||
// 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变
|
||
export type { ChatState } from "./chat-state";
|
||
export { initialState } from "./chat-state";
|
||
export type { ChatEvent } from "./chat-events";
|
||
|
||
// ============================================================
|
||
// Machine
|
||
// ============================================================
|
||
export const chatMachine = setup({
|
||
types: {
|
||
context: {} as ChatState,
|
||
events: {} as ChatEvent,
|
||
},
|
||
actors: {
|
||
loadQuota: loadQuotaActor,
|
||
loadHistory: loadHistoryActor,
|
||
sendMessageHttp: sendMessageHttpActor,
|
||
sendMessageWs: sendMessageWsActor,
|
||
loadMoreHistory: loadMoreHistoryActor,
|
||
chatWebSocket: chatWebSocketActor,
|
||
},
|
||
actions: {
|
||
startGuestSession: assign(() => ({
|
||
...initialState,
|
||
})),
|
||
|
||
startUserSession: assign(() => ({
|
||
...initialState,
|
||
})),
|
||
|
||
clearChatSession: assign(() => ({
|
||
...initialState,
|
||
})),
|
||
|
||
appendGuestUserMessage: assign(({ context, event }) => {
|
||
if (event.type !== "ChatSendMessage") return {};
|
||
|
||
// 游客消息发送成功前先乐观扣减本地日配额 + 总配额。
|
||
const newRemaining = Math.max(0, context.guestRemainingQuota - 1);
|
||
const newTotal = Math.max(0, context.guestTotalQuota - 1);
|
||
|
||
// 持久化两个额度( fire-and-forget,不 await —— assign 是 sync)
|
||
// daily quota 需要 today 字符串(让 ChatStorage 跨天判断 reset)
|
||
const today = todayString();
|
||
ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today);
|
||
|
||
ChatStorage.getInstance().setGuestTotalQuota(newTotal);
|
||
|
||
log.debug("[chat-machine] appendGuestUserMessage", {
|
||
contentLength: event.content.length,
|
||
contentPreview: event.content.slice(0, 50),
|
||
quotaMode: "guest http (decrement)",
|
||
oldRemaining: context.guestRemainingQuota,
|
||
newRemaining,
|
||
oldTotal: context.guestTotalQuota,
|
||
newTotal,
|
||
isReplyingAI: true,
|
||
});
|
||
|
||
return {
|
||
messages: [
|
||
...context.messages,
|
||
{
|
||
content: event.content,
|
||
isFromAI: false,
|
||
date: today,
|
||
},
|
||
],
|
||
isReplyingAI: true,
|
||
guestRemainingQuota: newRemaining,
|
||
guestTotalQuota: newTotal,
|
||
paywallTriggered: false,
|
||
paywallReason: null,
|
||
paywallDetail: null,
|
||
};
|
||
}),
|
||
|
||
appendUserMessage: assign(({ context, event }) => {
|
||
if (event.type !== "ChatSendMessage") return {};
|
||
|
||
const today = todayString();
|
||
log.debug("[chat-machine] appendUserMessage", {
|
||
contentLength: event.content.length,
|
||
contentPreview: event.content.slice(0, 50),
|
||
wsConnected: context.wsConnected,
|
||
isReplyingAI: true,
|
||
});
|
||
|
||
return {
|
||
messages: [
|
||
...context.messages,
|
||
{
|
||
content: event.content,
|
||
isFromAI: false,
|
||
date: today,
|
||
},
|
||
],
|
||
isReplyingAI: true,
|
||
paywallTriggered: false,
|
||
paywallReason: null,
|
||
paywallDetail: null,
|
||
};
|
||
}),
|
||
|
||
appendGuestUserImage: assign(({ context, event }) => {
|
||
if (event.type !== "ChatSendImage") return {};
|
||
// 同 appendGuestUserMessage:游客两个额度同步减
|
||
const newRemaining = Math.max(0, context.guestRemainingQuota - 1);
|
||
const newTotal = Math.max(0, context.guestTotalQuota - 1);
|
||
|
||
const today = todayString();
|
||
void ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today);
|
||
|
||
void ChatStorage.getInstance().setGuestTotalQuota(newTotal);
|
||
|
||
log.debug("[chat-machine] appendGuestUserImage", {
|
||
oldMessagesCount: context.messages.length,
|
||
quotaMode: "guest http (decrement)",
|
||
oldRemaining: context.guestRemainingQuota,
|
||
newRemaining,
|
||
oldTotal: context.guestTotalQuota,
|
||
newTotal,
|
||
});
|
||
return {
|
||
messages: [
|
||
...context.messages,
|
||
{
|
||
content: "[Image]",
|
||
isFromAI: false,
|
||
date: today,
|
||
imageUrl: event.imageBase64,
|
||
},
|
||
],
|
||
isReplyingAI: true,
|
||
guestRemainingQuota: newRemaining,
|
||
guestTotalQuota: newTotal,
|
||
paywallTriggered: false,
|
||
paywallReason: null,
|
||
paywallDetail: null,
|
||
};
|
||
}),
|
||
|
||
appendUserImage: assign(({ context, event }) => {
|
||
if (event.type !== "ChatSendImage") return {};
|
||
|
||
const today = todayString();
|
||
log.debug("[chat-machine] appendUserImage", {
|
||
oldMessagesCount: context.messages.length,
|
||
wsConnected: context.wsConnected,
|
||
});
|
||
|
||
return {
|
||
messages: [
|
||
...context.messages,
|
||
{
|
||
content: "[Image]",
|
||
isFromAI: false,
|
||
date: today,
|
||
imageUrl: event.imageBase64,
|
||
},
|
||
],
|
||
isReplyingAI: true,
|
||
paywallTriggered: false,
|
||
paywallReason: null,
|
||
paywallDetail: null,
|
||
};
|
||
}),
|
||
|
||
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}`,
|
||
};
|
||
}
|
||
}
|
||
log.debug("[chat-machine] appendOrUpdateAISentence", {
|
||
index: event.index,
|
||
total: event.total,
|
||
done: event.done,
|
||
messagesCount: messages.length,
|
||
});
|
||
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 };
|
||
}),
|
||
|
||
incrementQuotaExceeded: assign(({ context }) => ({
|
||
quotaExceededTrigger: context.quotaExceededTrigger + 1,
|
||
})),
|
||
|
||
setWsConnected: assign({ wsConnected: true }),
|
||
clearWsConnected: assign({ wsConnected: false }),
|
||
},
|
||
}).createMachine({
|
||
id: "chat",
|
||
initial: "idle",
|
||
context: initialState,
|
||
states: {
|
||
idle: {
|
||
on: {
|
||
ChatGuestLogin: {
|
||
target: "#chat.guestSession",
|
||
actions: "startGuestSession",
|
||
},
|
||
ChatNonVipLogin: {
|
||
target: "#chat.nonVipUserSession",
|
||
actions: "startUserSession",
|
||
},
|
||
ChatVipLogin: {
|
||
target: "#chat.vipUserSession",
|
||
actions: "startUserSession",
|
||
},
|
||
},
|
||
},
|
||
|
||
guestSession: {
|
||
on: {
|
||
ChatLogout: {
|
||
target: "#chat.idle",
|
||
actions: "clearChatSession",
|
||
},
|
||
ChatNonVipLogin: {
|
||
target: "#chat.nonVipUserSession",
|
||
actions: "startUserSession",
|
||
},
|
||
ChatVipLogin: {
|
||
target: "#chat.vipUserSession",
|
||
actions: "startUserSession",
|
||
},
|
||
},
|
||
initial: "initializing",
|
||
states: {
|
||
initializing: {
|
||
// "always" barrier:两个任务都完成才进 ready
|
||
always: [
|
||
{
|
||
target: "ready",
|
||
guard: ({ context }) => context.quotaLoaded && context.historyLoaded,
|
||
},
|
||
],
|
||
invoke: [
|
||
// 任务 1:加载配额(fromPromise,返结果 + onDone 设 flag)
|
||
{
|
||
id: "loadQuota",
|
||
src: "loadQuota",
|
||
onDone: {
|
||
actions: assign(({ event }) => ({
|
||
guestRemainingQuota: event.output.remaining,
|
||
guestTotalQuota: event.output.total,
|
||
quotaLoaded: true,
|
||
})),
|
||
},
|
||
onError: {
|
||
// 失败也标 loaded,不卡 init(游客可能 quota 服务不可用,让 UI 进 ready)
|
||
actions: assign({
|
||
quotaLoaded: true,
|
||
}),
|
||
},
|
||
},
|
||
// 任务 2:拉 history(fromPromise,返回最终 network messages)
|
||
{
|
||
id: "loadHistory",
|
||
src: "loadHistory",
|
||
onDone: {
|
||
actions: assign(({ event }) => ({
|
||
messages: event.output.messages,
|
||
hasMore: event.output.hasMore,
|
||
historyOffset: event.output.newOffset,
|
||
historyLoaded: true,
|
||
})),
|
||
},
|
||
onError: {
|
||
// 失败也标 loaded,不卡 init(让 UI 还是能进 ready,屏幕可能空)
|
||
actions: assign({
|
||
historyLoaded: true,
|
||
}),
|
||
},
|
||
},
|
||
],
|
||
},
|
||
ready: {
|
||
on: {
|
||
// 配额检查 + 发送消息(3 条 guard,按顺序匹配)
|
||
// 1. 总配额(guestTotalQuota)不足 → 触发 quota exceeded dialog,不发送
|
||
// 2. 每日配额(guestRemainingQuota)不足 → 同上
|
||
// 3. 两个配额都 OK + 内容非空 → 发送 + 减配额
|
||
ChatSendMessage: [
|
||
// 1. 总配额 不足(guestTotalQuota <= 0) → 触发 quota exceeded dialog
|
||
{
|
||
guard: ({ context }) => context.guestTotalQuota <= 0,
|
||
actions: "incrementQuotaExceeded",
|
||
},
|
||
// 2. 每日配额 不足(guestRemainingQuota <= 0) → 触发 quota exceeded dialog
|
||
{
|
||
guard: ({ context }) => context.guestRemainingQuota <= 0,
|
||
actions: "incrementQuotaExceeded",
|
||
},
|
||
// 3. 两个配额都 OK + 内容非空 → 发送(只用 appendUserMessage 自己的日志)
|
||
{
|
||
actions: "appendGuestUserMessage",
|
||
guard: ({ event }) => event.content.trim().length > 0,
|
||
target: "sending",
|
||
},
|
||
],
|
||
// 配额检查 + 发送图片(同样 3 条 guard,不检查 "内容空")
|
||
ChatSendImage: [
|
||
{
|
||
guard: ({ context }) => context.guestTotalQuota <= 0,
|
||
actions: "incrementQuotaExceeded",
|
||
},
|
||
{
|
||
guard: ({ context }) => context.guestRemainingQuota <= 0,
|
||
actions: "incrementQuotaExceeded",
|
||
},
|
||
{
|
||
actions: "appendGuestUserImage",
|
||
target: "sending",
|
||
},
|
||
],
|
||
// 删除 ChatLoadMoreHistory / WS handlers —— 游客无服务端 history,也不连接 WS
|
||
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
|
||
},
|
||
},
|
||
sending: {
|
||
invoke: {
|
||
src: "sendMessageHttp",
|
||
input: ({ event }) => ({
|
||
content: event.type === "ChatSendMessage" ? event.content : "",
|
||
}),
|
||
onDone: {
|
||
target: "ready",
|
||
actions: assign(({ context, event }) =>
|
||
applyHttpSendOutput(context, event.output),
|
||
),
|
||
},
|
||
onError: {
|
||
target: "ready",
|
||
actions: assign({ isReplyingAI: false }),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
|
||
nonVipUserSession: {
|
||
on: {
|
||
ChatLogout: {
|
||
target: "#chat.idle",
|
||
actions: "clearChatSession",
|
||
},
|
||
ChatGuestLogin: {
|
||
target: "#chat.guestSession",
|
||
actions: "startGuestSession",
|
||
},
|
||
ChatNonVipLogin: {
|
||
target: "#chat.nonVipUserSession",
|
||
reenter: true,
|
||
actions: "startUserSession",
|
||
},
|
||
ChatVipLogin: {
|
||
target: "#chat.vipUserSession",
|
||
actions: "startUserSession",
|
||
},
|
||
},
|
||
initial: "initializing",
|
||
states: {
|
||
initializing: {
|
||
invoke: {
|
||
src: "loadHistory",
|
||
onDone: {
|
||
target: "ready",
|
||
actions: assign(({ event }) => ({
|
||
messages: event.output.messages,
|
||
isLoadingMore: false,
|
||
hasMore: event.output.hasMore,
|
||
historyOffset: event.output.newOffset,
|
||
historyLoaded: true,
|
||
})),
|
||
},
|
||
onError: {
|
||
target: "ready",
|
||
actions: assign({
|
||
isLoadingMore: false,
|
||
historyLoaded: true,
|
||
}),
|
||
},
|
||
},
|
||
},
|
||
ready: {
|
||
on: {
|
||
ChatSendMessage: {
|
||
guard: ({ event }) => event.content.trim().length > 0,
|
||
actions: "appendUserMessage",
|
||
target: "sendingViaHttp",
|
||
},
|
||
ChatSendImage: {
|
||
actions: "appendUserImage",
|
||
},
|
||
ChatLoadMoreHistory: {
|
||
target: "loadingMore",
|
||
},
|
||
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
|
||
},
|
||
},
|
||
sendingViaHttp: {
|
||
invoke: {
|
||
src: "sendMessageHttp",
|
||
input: ({ event }) => ({
|
||
content: event.type === "ChatSendMessage" ? event.content : "",
|
||
}),
|
||
onDone: {
|
||
target: "ready",
|
||
actions: assign(({ context, event }) =>
|
||
applyHttpSendOutput(context, event.output),
|
||
),
|
||
},
|
||
onError: {
|
||
target: "ready",
|
||
actions: assign({ isReplyingAI: false }),
|
||
},
|
||
},
|
||
},
|
||
loadingMore: {
|
||
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 }),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
|
||
vipUserSession: {
|
||
// 父级 on:把 WS / AI 流句 事件从 ready.on 上提到 vipUserSession.on
|
||
// —— 任何 child state(initializing / ready / sendingViaWs / sendingViaHttp / loadingMore)
|
||
// 都能收到 `ChatAISentenceReceived` 并把 AI 句 push 到 messages。
|
||
//
|
||
// 子级 override 规则:
|
||
// - sendingViaWs 重新声明 `ChatWebSocketError` 加 target: "ready"(带 action)
|
||
// —— XState v5: child handler 替换 parent;复制 action
|
||
on: {
|
||
ChatLogout: {
|
||
target: "#chat.idle",
|
||
actions: "clearChatSession",
|
||
},
|
||
ChatGuestLogin: {
|
||
target: "#chat.guestSession",
|
||
actions: "startGuestSession",
|
||
},
|
||
ChatNonVipLogin: {
|
||
target: "#chat.nonVipUserSession",
|
||
actions: "startUserSession",
|
||
},
|
||
ChatVipLogin: {
|
||
target: "#chat.vipUserSession",
|
||
reenter: true,
|
||
actions: "startUserSession",
|
||
},
|
||
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
|
||
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
|
||
ChatWebSocketConnected: { actions: "setWsConnected" },
|
||
},
|
||
initial: "initializing",
|
||
exit: "clearWsConnected",
|
||
invoke: {
|
||
// 父级:WS 长连接(一进来就连,跨 initializing/ready/sending*/loadingMore)
|
||
src: "chatWebSocket",
|
||
input: ({ event }) => ({
|
||
token: event.type === "ChatVipLogin" ? event.token : "",
|
||
}),
|
||
},
|
||
states: {
|
||
initializing: {
|
||
// barrier:WS 连上 + history 加载完成才进 ready
|
||
always: [
|
||
{
|
||
target: "ready",
|
||
guard: ({ context }) => context.wsConnected && context.historyLoaded,
|
||
},
|
||
],
|
||
invoke: {
|
||
// 任务 2:拉 history(只调用 loadHistoryActor,不再用 loadMoreHistoryActor)
|
||
src: "loadHistory",
|
||
onDone: {
|
||
actions: assign(({ event }) => ({
|
||
messages: event.output.messages,
|
||
isLoadingMore: false,
|
||
hasMore: event.output.hasMore,
|
||
historyOffset: event.output.newOffset,
|
||
historyLoaded: true,
|
||
})),
|
||
},
|
||
onError: {
|
||
// 失败也标 loaded,不卡 init
|
||
actions: assign({
|
||
isLoadingMore: false,
|
||
historyLoaded: true,
|
||
}),
|
||
},
|
||
},
|
||
},
|
||
ready: {
|
||
on: {
|
||
// 发送消息:wsConnected → 走 WS(流式回 reply),否则 → 走 HTTP(一次性回 reply)
|
||
// 两个 branch 都有 "content 非空" guard(仅作 fallback 兜底:wsConnected=true 也需
|
||
// 内容非空;wsConnected=false 同理)
|
||
ChatSendMessage: [
|
||
{
|
||
// VIP 用户优先走 WS;WS 尚未可用时由下一个 branch 走 HTTP fallback。
|
||
guard: ({ context, event }) =>
|
||
context.wsConnected &&
|
||
event.content.trim().length > 0,
|
||
actions: "appendUserMessage",
|
||
target: "sendingViaWs",
|
||
},
|
||
{
|
||
// WS 未连(或 fallback) + 内容非空 → 走 HTTP(sendingViaHttp 一次性回 reply)
|
||
guard: ({ event }) => event.content.trim().length > 0,
|
||
actions: "appendUserMessage",
|
||
target: "sendingViaHttp",
|
||
},
|
||
],
|
||
ChatSendImage: {
|
||
actions: "appendUserImage",
|
||
},
|
||
ChatLoadMoreHistory: {
|
||
target: "loadingMore",
|
||
},
|
||
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
|
||
// 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected
|
||
// 已上提到 vipUserSession.on,子级不再声明
|
||
},
|
||
},
|
||
// WS 发送:invoke sendMessageWsActor 触发一次 send,
|
||
// 之后 AI 流式回 reply 走 vipUserSession.on 的 ChatAISentenceReceived → appendOrUpdateAISentence
|
||
// 当句尾 done: true → isReplyingAI = false → always 跳回 ready
|
||
sendingViaWs: {
|
||
on: {
|
||
// 流式回包中 WS 出错(mid-stream) → 加错误泡 + 跳回 ready(不卡在 sendingViaWs)
|
||
// 这个 child handler 替换 vipUserSession.on 的 ChatWebSocketError(XState v5 不合并)
|
||
// 所以需要重复声明 action: "appendSocketErrorMessage"
|
||
ChatWebSocketError: {
|
||
target: "ready",
|
||
actions: "appendSocketErrorMessage",
|
||
},
|
||
},
|
||
invoke: {
|
||
src: "sendMessageWs",
|
||
input: ({ event }) => ({
|
||
content: event.type === "ChatSendMessage" ? event.content : "",
|
||
}),
|
||
onError: {
|
||
// sendMessageWsActor 自身抛错(无 active ws / ws not OPEN) → 回 ready + 清 typing
|
||
target: "ready",
|
||
actions: assign({ isReplyingAI: false }),
|
||
},
|
||
},
|
||
// 监听 isReplyingAI —— appendOrUpdateAISentence 在 done: true 时设 false
|
||
always: [
|
||
{
|
||
target: "ready",
|
||
guard: ({ context }) => !context.isReplyingAI,
|
||
},
|
||
],
|
||
},
|
||
// HTTP 发送:一次发送 = 一次返回(保留原 sending 状态语义,guest 与 ws-down fallback 走此)
|
||
sendingViaHttp: {
|
||
invoke: {
|
||
src: "sendMessageHttp",
|
||
input: ({ event }) => ({
|
||
content: event.type === "ChatSendMessage" ? event.content : "",
|
||
}),
|
||
onDone: {
|
||
target: "ready",
|
||
actions: assign(({ context, event }) =>
|
||
applyHttpSendOutput(context, event.output),
|
||
),
|
||
},
|
||
onError: {
|
||
target: "ready",
|
||
actions: assign({ isReplyingAI: false }),
|
||
},
|
||
},
|
||
},
|
||
loadingMore: {
|
||
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;
|