feat(chat): handle daily message paywall
This commit is contained in:
@@ -19,13 +19,15 @@
|
||||
* - 每个任务都是独立 actor,不互相等待(除非 `always` barrier)
|
||||
*
|
||||
* 消息发送路径(业务事实):
|
||||
* - `wsConnected: true`(仅 userSession 期间)→ 走 WS,不消耗次数(仅配额规则)
|
||||
* - `wsConnected: false` → 走 HTTP,消耗次数(仅游客有意义 —— 非游客 0-1=max(0,-1)=0 天然 no-op)
|
||||
* - guestSession:走 HTTP,并先检查本地游客配额
|
||||
* - userSession + VIP + WS 已连:走 WS,不受每日免费次数限制
|
||||
* - userSession + 非 VIP:走 HTTP,让后端返回 daily_limit blocked
|
||||
* - `ChatWebSocketConnected` 事件 → `wsConnected = true`
|
||||
* - `userSession.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 已连时不递减
|
||||
*
|
||||
*/
|
||||
@@ -35,9 +37,9 @@ 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,
|
||||
@@ -49,12 +51,6 @@ import {
|
||||
|
||||
const log = new Logger("StoresChatChatMachine");
|
||||
|
||||
/** 游客配额阈值(与 Dart `GuestChatQuota` 保持一致) */
|
||||
export const GuestChatQuota = {
|
||||
warningThreshold: 5,
|
||||
quotaExhausted: 0,
|
||||
} as const;
|
||||
|
||||
// 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变
|
||||
export type { ChatState } from "./chat-state";
|
||||
export { initialState } from "./chat-state";
|
||||
@@ -77,6 +73,23 @@ export const chatMachine = setup({
|
||||
chatWebSocket: chatWebSocketActor,
|
||||
},
|
||||
actions: {
|
||||
startGuestSession: assign(() => ({
|
||||
...initialState,
|
||||
isVip: false,
|
||||
})),
|
||||
|
||||
startUserSession: assign(({ event }) => {
|
||||
if (event.type !== "ChatNonGuestLogin") return {};
|
||||
return {
|
||||
...initialState,
|
||||
isVip: event.isVip,
|
||||
};
|
||||
}),
|
||||
|
||||
clearChatSession: assign(() => ({
|
||||
...initialState,
|
||||
})),
|
||||
|
||||
appendUserMessage: assign(({ context, event }) => {
|
||||
if (event.type !== "ChatSendMessage") return {};
|
||||
|
||||
@@ -118,6 +131,9 @@ export const chatMachine = setup({
|
||||
isReplyingAI: true,
|
||||
guestRemainingQuota: newRemaining,
|
||||
guestTotalQuota: newTotal,
|
||||
paywallTriggered: false,
|
||||
paywallReason: null,
|
||||
paywallDetail: null,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -158,6 +174,9 @@ export const chatMachine = setup({
|
||||
isReplyingAI: true,
|
||||
guestRemainingQuota: newRemaining,
|
||||
guestTotalQuota: newTotal,
|
||||
paywallTriggered: false,
|
||||
paywallReason: null,
|
||||
paywallDetail: null,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -214,15 +233,27 @@ export const chatMachine = setup({
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
ChatGuestLogin: "#chat.guestSession",
|
||||
ChatNonGuestLogin: "#chat.userSession",
|
||||
ChatGuestLogin: {
|
||||
target: "#chat.guestSession",
|
||||
actions: "startGuestSession",
|
||||
},
|
||||
ChatNonGuestLogin: {
|
||||
target: "#chat.userSession",
|
||||
actions: "startUserSession",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
guestSession: {
|
||||
on: {
|
||||
ChatLogout: "#chat.idle",
|
||||
ChatNonGuestLogin: "#chat.userSession",
|
||||
ChatLogout: {
|
||||
target: "#chat.idle",
|
||||
actions: "clearChatSession",
|
||||
},
|
||||
ChatNonGuestLogin: {
|
||||
target: "#chat.userSession",
|
||||
actions: "startUserSession",
|
||||
},
|
||||
},
|
||||
initial: "initializing",
|
||||
states: {
|
||||
@@ -328,10 +359,9 @@ export const chatMachine = setup({
|
||||
}),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: assign(({ context, event }) => ({
|
||||
messages: [...context.messages, event.output.reply],
|
||||
isReplyingAI: false,
|
||||
})),
|
||||
actions: assign(({ context, event }) =>
|
||||
applyHttpSendOutput(context, event.output),
|
||||
),
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
@@ -352,10 +382,18 @@ export const chatMachine = setup({
|
||||
// - sendingViaWs 重新声明 `ChatWebSocketError` 加 target: "ready"(带 action)
|
||||
// —— XState v5: child handler 替换 parent;复制 action
|
||||
on: {
|
||||
ChatLogout: "#chat.idle",
|
||||
ChatLogout: {
|
||||
target: "#chat.idle",
|
||||
actions: "clearChatSession",
|
||||
},
|
||||
ChatGuestLogin: {
|
||||
target: "#chat.guestSession",
|
||||
actions: "startGuestSession",
|
||||
},
|
||||
ChatNonGuestLogin: {
|
||||
target: "#chat.userSession",
|
||||
reenter: true,
|
||||
actions: "startUserSession",
|
||||
},
|
||||
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
|
||||
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
|
||||
@@ -407,9 +445,11 @@ export const chatMachine = setup({
|
||||
// 内容非空;wsConnected=false 同理)
|
||||
ChatSendMessage: [
|
||||
{
|
||||
// WS 已连 + 内容非空 → 走 WS(sendingViaWs 等 AI 流式回 reply)
|
||||
// VIP 用户保留 WS;非 VIP 走 HTTP,让后端 daily_limit 能返回 blocked。
|
||||
guard: ({ context, event }) =>
|
||||
context.wsConnected && event.content.trim().length > 0,
|
||||
context.isVip &&
|
||||
context.wsConnected &&
|
||||
event.content.trim().length > 0,
|
||||
actions: "appendUserMessage",
|
||||
target: "sendingViaWs",
|
||||
},
|
||||
@@ -472,10 +512,9 @@ export const chatMachine = setup({
|
||||
}),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: assign(({ context, event }) => ({
|
||||
messages: [...context.messages, event.output.reply],
|
||||
isReplyingAI: false,
|
||||
})),
|
||||
actions: assign(({ context, event }) =>
|
||||
applyHttpSendOutput(context, event.output),
|
||||
),
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
|
||||
Reference in New Issue
Block a user