feat(chat): handle daily message paywall
This commit is contained in:
@@ -1,7 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
/**
|
|
||||||
* ChatScreen 编排层
|
|
||||||
*/
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
@@ -50,8 +48,13 @@ export function ChatScreen() {
|
|||||||
// - quotaExceededTrigger 而非 guestTotalQuota / guestRemainingQuota ——
|
// - quotaExceededTrigger 而非 guestTotalQuota / guestRemainingQuota ——
|
||||||
// trigger 由 chat 机器的 guard 在 ChatSendMessage / ChatSendImage 被拦截时 +1,
|
// trigger 由 chat 机器的 guard 在 ChatSendMessage / ChatSendImage 被拦截时 +1,
|
||||||
// 是 "用户被配额拒绝过" 的权威信号(避免依赖 quota 数值本身的边界判断)
|
// 是 "用户被配额拒绝过" 的权威信号(避免依赖 quota 数值本身的边界判断)
|
||||||
const showExhaustedBanner =
|
const showGuestQuotaBanner =
|
||||||
isGuest && state.quotaLoaded && state.quotaExceededTrigger > 0;
|
isGuest && state.quotaLoaded && state.quotaExceededTrigger > 0;
|
||||||
|
const showDailyLimitBanner =
|
||||||
|
!isGuest &&
|
||||||
|
state.paywallTriggered &&
|
||||||
|
state.paywallReason === "daily_limit";
|
||||||
|
const showExhaustedBanner = showGuestQuotaBanner || showDailyLimitBanner;
|
||||||
|
|
||||||
const externalBrowserPromptShownRef = useRef(false);
|
const externalBrowserPromptShownRef = useRef(false);
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function ChatQuotaExhaustedBanner({
|
|||||||
onUnlock();
|
onUnlock();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(ROUTES.auth);
|
router.push(`${ROUTES.subscription}?type=vip`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { AppEnvUtil } from "@/utils";
|
|||||||
|
|
||||||
export class GuestChatQuota {
|
export class GuestChatQuota {
|
||||||
// 静态常量
|
// 静态常量
|
||||||
static readonly maxQuotaPerDay = 40;
|
static readonly maxQuotaPerDay = 30;
|
||||||
static readonly maxQuotaPerDayTest = 4;
|
static readonly maxQuotaPerDayTest = 4;
|
||||||
|
|
||||||
static readonly defaultTotalQuota = 50;
|
static readonly defaultTotalQuota = 50;
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
|
||||||
import type { LoginStatus } from "@/data/dto/auth";
|
|
||||||
import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
||||||
import { useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthState } from "@/stores/auth/auth-context";
|
||||||
|
import { useUserState } from "@/stores/user/user-context";
|
||||||
import { PROTECTED_ROUTES, ROUTES, type StaticRoute } from "@/router/routes";
|
import { PROTECTED_ROUTES, ROUTES, type StaticRoute } from "@/router/routes";
|
||||||
|
|
||||||
import { useChatDispatch } from "./chat-context";
|
import { useChatDispatch } from "./chat-context";
|
||||||
@@ -23,17 +23,22 @@ function isProtectedPath(pathname: string): boolean {
|
|||||||
|
|
||||||
export function ChatAuthSync() {
|
export function ChatAuthSync() {
|
||||||
const authState = useAuthState();
|
const authState = useAuthState();
|
||||||
|
const userState = useUserState();
|
||||||
const chatDispatch = useChatDispatch();
|
const chatDispatch = useChatDispatch();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const prevLoginStatusRef = useRef<LoginStatus | null>(null);
|
const prevSessionKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authState.hasInitialized || authState.isLoading) return;
|
if (!authState.hasInitialized || authState.isLoading) return;
|
||||||
|
|
||||||
const prev = prevLoginStatusRef.current;
|
const isVip = userState.currentUser?.isVip === true;
|
||||||
if (prev === authState.loginStatus) return;
|
const sessionKey =
|
||||||
prevLoginStatusRef.current = authState.loginStatus;
|
authState.loginStatus === "notLoggedIn" || authState.loginStatus === "guest"
|
||||||
|
? authState.loginStatus
|
||||||
|
: `${authState.loginStatus}:${isVip ? "vip" : "non-vip"}`;
|
||||||
|
if (prevSessionKeyRef.current === sessionKey) return;
|
||||||
|
prevSessionKeyRef.current = sessionKey;
|
||||||
|
|
||||||
if (authState.loginStatus === "notLoggedIn") {
|
if (authState.loginStatus === "notLoggedIn") {
|
||||||
chatDispatch({ type: "ChatLogout" });
|
chatDispatch({ type: "ChatLogout" });
|
||||||
@@ -52,7 +57,11 @@ export function ChatAuthSync() {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
const tokenR = await AuthStorage.getInstance().getLoginToken();
|
const tokenR = await AuthStorage.getInstance().getLoginToken();
|
||||||
if (!cancelled && tokenR.success && tokenR.data) {
|
if (!cancelled && tokenR.success && tokenR.data) {
|
||||||
chatDispatch({ type: "ChatNonGuestLogin", token: tokenR.data });
|
chatDispatch({
|
||||||
|
type: "ChatNonGuestLogin",
|
||||||
|
token: tokenR.data,
|
||||||
|
isVip,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -66,6 +75,7 @@ export function ChatAuthSync() {
|
|||||||
chatDispatch,
|
chatDispatch,
|
||||||
pathname,
|
pathname,
|
||||||
router,
|
router,
|
||||||
|
userState.currentUser?.isVip,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ interface ChatState {
|
|||||||
/** 游客总配额 */
|
/** 游客总配额 */
|
||||||
guestTotalQuota: number;
|
guestTotalQuota: number;
|
||||||
quotaExceededTrigger: number;
|
quotaExceededTrigger: number;
|
||||||
|
paywallTriggered: boolean;
|
||||||
|
paywallReason: MachineContext["paywallReason"];
|
||||||
|
paywallDetail: MachineContext["paywallDetail"];
|
||||||
isLoadingMore: boolean;
|
isLoadingMore: boolean;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
historyOffset: number;
|
historyOffset: number;
|
||||||
@@ -53,6 +56,9 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
|||||||
guestRemainingQuota: state.context.guestRemainingQuota,
|
guestRemainingQuota: state.context.guestRemainingQuota,
|
||||||
guestTotalQuota: state.context.guestTotalQuota,
|
guestTotalQuota: state.context.guestTotalQuota,
|
||||||
quotaExceededTrigger: state.context.quotaExceededTrigger,
|
quotaExceededTrigger: state.context.quotaExceededTrigger,
|
||||||
|
paywallTriggered: state.context.paywallTriggered,
|
||||||
|
paywallReason: state.context.paywallReason,
|
||||||
|
paywallDetail: state.context.paywallDetail,
|
||||||
isLoadingMore: state.context.isLoadingMore,
|
isLoadingMore: state.context.isLoadingMore,
|
||||||
hasMore: state.context.hasMore,
|
hasMore: state.context.hasMore,
|
||||||
historyOffset: state.context.historyOffset,
|
historyOffset: state.context.historyOffset,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
export type ChatEvent =
|
export type ChatEvent =
|
||||||
// 鉴权生命周期(登录态变化后由 ChatAuthSync 派)
|
// 鉴权生命周期(登录态变化后由 ChatAuthSync 派)
|
||||||
| { type: "ChatGuestLogin" }
|
| { type: "ChatGuestLogin" }
|
||||||
| { type: "ChatNonGuestLogin"; token: string }
|
| { type: "ChatNonGuestLogin"; token: string; isVip: boolean }
|
||||||
| { type: "ChatLogout" }
|
| { type: "ChatLogout" }
|
||||||
// 业务事件
|
// 业务事件
|
||||||
| { type: "ChatSendMessage"; content: string }
|
| { type: "ChatSendMessage"; content: string }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { fromPromise, fromCallback } from "xstate";
|
import { fromPromise, fromCallback } from "xstate";
|
||||||
|
|
||||||
import { createChatWebSocket, ChatWebSocket } from "@/core/net/chat-websocket";
|
import { createChatWebSocket, ChatWebSocket } from "@/core/net/chat-websocket";
|
||||||
|
import type { ChatSendResponse } from "@/data/dto/chat";
|
||||||
import { Result, Logger } from "@/utils";
|
import { Result, Logger } from "@/utils";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -83,10 +84,10 @@ export const loadHistoryActor = fromPromise<{
|
|||||||
* HTTP 发送消息(一次发送 = 一次返回)
|
* HTTP 发送消息(一次发送 = 一次返回)
|
||||||
* - 后端响应就是 AI 回复(`ChatSendResponse.reply: string`)
|
* - 后端响应就是 AI 回复(`ChatSendResponse.reply: string`)
|
||||||
* - 不再调 `getLocalMessages()`(多此一举)
|
* - 不再调 `getLocalMessages()`(多此一举)
|
||||||
* - 返回 `{ reply: UiMessage }` —— `sending.onDone` 把它追加到 `context.messages`
|
* - 返回完整 response + 可追加的 reply,方便处理后端 paywall blocked 响应
|
||||||
*/
|
*/
|
||||||
export const sendMessageHttpActor = fromPromise<
|
export const sendMessageHttpActor = fromPromise<
|
||||||
{ reply: UiMessage },
|
{ response: ChatSendResponse; reply: UiMessage | null },
|
||||||
{ content: string }
|
{ content: string }
|
||||||
>(async ({ input }) => {
|
>(async ({ input }) => {
|
||||||
const result = await chatRepo.sendMessage(input.content);
|
const result = await chatRepo.sendMessage(input.content);
|
||||||
@@ -94,7 +95,10 @@ export const sendMessageHttpActor = fromPromise<
|
|||||||
log.error("[chat-machine] sendMessageHttpActor failed", { error: result.error });
|
log.error("[chat-machine] sendMessageHttpActor failed", { error: result.error });
|
||||||
throw result.error;
|
throw result.error;
|
||||||
}
|
}
|
||||||
return { reply: sendResponseToUiMessage(result.data) };
|
return {
|
||||||
|
response: result.data,
|
||||||
|
reply: result.data.blocked ? null : sendResponseToUiMessage(result.data),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 翻历史(pagination)—— 不走 local-first,纯 server fetch */
|
/** 翻历史(pagination)—— 不走 local-first,纯 server fetch */
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* 设计目标:
|
* 设计目标:
|
||||||
* - helpers 无 XState 依赖(可独立 import / 测试)
|
* - helpers 无 XState 依赖(可独立 import / 测试)
|
||||||
* - actors 依赖 helpers(import)
|
* - actors 依赖 helpers(import)
|
||||||
* - machine 依赖 actors(import)—— 不直接依赖 helpers
|
* - machine 依赖 actors,并复用 helpers 里的纯状态转换函数
|
||||||
*
|
*
|
||||||
* 历史:
|
* 历史:
|
||||||
* - `readInitData` + `InitResult` 已删(被 2 个独立 helper 替换)
|
* - `readInitData` + `InitResult` 已删(被 2 个独立 helper 替换)
|
||||||
@@ -27,6 +27,8 @@ import { ChatStorage } from "@/data/storage/chat/chat_storage";
|
|||||||
import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
|
import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
|
||||||
import { formatDate, Result, Logger } from "@/utils";
|
import { formatDate, Result, Logger } from "@/utils";
|
||||||
|
|
||||||
|
import type { ChatState } from "./chat-state";
|
||||||
|
|
||||||
const log = new Logger("StoresChatChatMachineHelpers");
|
const log = new Logger("StoresChatChatMachineHelpers");
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -76,6 +78,51 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HttpSendOutput = {
|
||||||
|
response: ChatSendResponse;
|
||||||
|
reply: UiMessage | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function applyHttpSendOutput(
|
||||||
|
context: ChatState,
|
||||||
|
output: HttpSendOutput,
|
||||||
|
): Partial<ChatState> {
|
||||||
|
const { response, reply } = output;
|
||||||
|
|
||||||
|
if (response.blocked === true && response.blockReason === "daily_limit") {
|
||||||
|
const detail = response.blockDetail;
|
||||||
|
const lastMessage = context.messages[context.messages.length - 1];
|
||||||
|
const messages =
|
||||||
|
lastMessage && !lastMessage.isFromAI
|
||||||
|
? context.messages.slice(0, -1)
|
||||||
|
: context.messages;
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
isReplyingAI: false,
|
||||||
|
paywallTriggered: true,
|
||||||
|
paywallReason: "daily_limit",
|
||||||
|
paywallDetail: detail
|
||||||
|
? { usedToday: detail.usedToday, limit: detail.limit }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reply) {
|
||||||
|
return {
|
||||||
|
isReplyingAI: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: [...context.messages, reply],
|
||||||
|
isReplyingAI: false,
|
||||||
|
paywallTriggered: false,
|
||||||
|
paywallReason: null,
|
||||||
|
paywallDetail: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Result → number 映射
|
// Result → number 映射
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -19,13 +19,15 @@
|
|||||||
* - 每个任务都是独立 actor,不互相等待(除非 `always` barrier)
|
* - 每个任务都是独立 actor,不互相等待(除非 `always` barrier)
|
||||||
*
|
*
|
||||||
* 消息发送路径(业务事实):
|
* 消息发送路径(业务事实):
|
||||||
* - `wsConnected: true`(仅 userSession 期间)→ 走 WS,不消耗次数(仅配额规则)
|
* - guestSession:走 HTTP,并先检查本地游客配额
|
||||||
* - `wsConnected: false` → 走 HTTP,消耗次数(仅游客有意义 —— 非游客 0-1=max(0,-1)=0 天然 no-op)
|
* - userSession + VIP + WS 已连:走 WS,不受每日免费次数限制
|
||||||
|
* - userSession + 非 VIP:走 HTTP,让后端返回 daily_limit blocked
|
||||||
* - `ChatWebSocketConnected` 事件 → `wsConnected = true`
|
* - `ChatWebSocketConnected` 事件 → `wsConnected = true`
|
||||||
* - `userSession.exit` action → `wsConnected = false`(WS actor cleanup 时也会跑 exit)
|
* - `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 已连时不递减
|
* - `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 { ChatStorage } from "@/data/storage/chat/chat_storage";
|
||||||
import { formatDate, todayString, Logger } from "@/utils";
|
import { formatDate, todayString, Logger } from "@/utils";
|
||||||
|
|
||||||
|
|
||||||
import { ChatState, initialState } from "./chat-state";
|
import { ChatState, initialState } from "./chat-state";
|
||||||
import type { ChatEvent } from "./chat-events";
|
import type { ChatEvent } from "./chat-events";
|
||||||
|
import { applyHttpSendOutput } from "./chat-machine.helpers";
|
||||||
import {
|
import {
|
||||||
loadQuotaActor,
|
loadQuotaActor,
|
||||||
loadHistoryActor,
|
loadHistoryActor,
|
||||||
@@ -49,12 +51,6 @@ import {
|
|||||||
|
|
||||||
const log = new Logger("StoresChatChatMachine");
|
const log = new Logger("StoresChatChatMachine");
|
||||||
|
|
||||||
/** 游客配额阈值(与 Dart `GuestChatQuota` 保持一致) */
|
|
||||||
export const GuestChatQuota = {
|
|
||||||
warningThreshold: 5,
|
|
||||||
quotaExhausted: 0,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变
|
// 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变
|
||||||
export type { ChatState } from "./chat-state";
|
export type { ChatState } from "./chat-state";
|
||||||
export { initialState } from "./chat-state";
|
export { initialState } from "./chat-state";
|
||||||
@@ -77,6 +73,23 @@ export const chatMachine = setup({
|
|||||||
chatWebSocket: chatWebSocketActor,
|
chatWebSocket: chatWebSocketActor,
|
||||||
},
|
},
|
||||||
actions: {
|
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 }) => {
|
appendUserMessage: assign(({ context, event }) => {
|
||||||
if (event.type !== "ChatSendMessage") return {};
|
if (event.type !== "ChatSendMessage") return {};
|
||||||
|
|
||||||
@@ -118,6 +131,9 @@ export const chatMachine = setup({
|
|||||||
isReplyingAI: true,
|
isReplyingAI: true,
|
||||||
guestRemainingQuota: newRemaining,
|
guestRemainingQuota: newRemaining,
|
||||||
guestTotalQuota: newTotal,
|
guestTotalQuota: newTotal,
|
||||||
|
paywallTriggered: false,
|
||||||
|
paywallReason: null,
|
||||||
|
paywallDetail: null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -158,6 +174,9 @@ export const chatMachine = setup({
|
|||||||
isReplyingAI: true,
|
isReplyingAI: true,
|
||||||
guestRemainingQuota: newRemaining,
|
guestRemainingQuota: newRemaining,
|
||||||
guestTotalQuota: newTotal,
|
guestTotalQuota: newTotal,
|
||||||
|
paywallTriggered: false,
|
||||||
|
paywallReason: null,
|
||||||
|
paywallDetail: null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -214,15 +233,27 @@ export const chatMachine = setup({
|
|||||||
states: {
|
states: {
|
||||||
idle: {
|
idle: {
|
||||||
on: {
|
on: {
|
||||||
ChatGuestLogin: "#chat.guestSession",
|
ChatGuestLogin: {
|
||||||
ChatNonGuestLogin: "#chat.userSession",
|
target: "#chat.guestSession",
|
||||||
|
actions: "startGuestSession",
|
||||||
|
},
|
||||||
|
ChatNonGuestLogin: {
|
||||||
|
target: "#chat.userSession",
|
||||||
|
actions: "startUserSession",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
guestSession: {
|
guestSession: {
|
||||||
on: {
|
on: {
|
||||||
ChatLogout: "#chat.idle",
|
ChatLogout: {
|
||||||
ChatNonGuestLogin: "#chat.userSession",
|
target: "#chat.idle",
|
||||||
|
actions: "clearChatSession",
|
||||||
|
},
|
||||||
|
ChatNonGuestLogin: {
|
||||||
|
target: "#chat.userSession",
|
||||||
|
actions: "startUserSession",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
initial: "initializing",
|
initial: "initializing",
|
||||||
states: {
|
states: {
|
||||||
@@ -328,10 +359,9 @@ export const chatMachine = setup({
|
|||||||
}),
|
}),
|
||||||
onDone: {
|
onDone: {
|
||||||
target: "ready",
|
target: "ready",
|
||||||
actions: assign(({ context, event }) => ({
|
actions: assign(({ context, event }) =>
|
||||||
messages: [...context.messages, event.output.reply],
|
applyHttpSendOutput(context, event.output),
|
||||||
isReplyingAI: false,
|
),
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
onError: {
|
onError: {
|
||||||
target: "ready",
|
target: "ready",
|
||||||
@@ -352,10 +382,18 @@ export const chatMachine = setup({
|
|||||||
// - sendingViaWs 重新声明 `ChatWebSocketError` 加 target: "ready"(带 action)
|
// - sendingViaWs 重新声明 `ChatWebSocketError` 加 target: "ready"(带 action)
|
||||||
// —— XState v5: child handler 替换 parent;复制 action
|
// —— XState v5: child handler 替换 parent;复制 action
|
||||||
on: {
|
on: {
|
||||||
ChatLogout: "#chat.idle",
|
ChatLogout: {
|
||||||
|
target: "#chat.idle",
|
||||||
|
actions: "clearChatSession",
|
||||||
|
},
|
||||||
|
ChatGuestLogin: {
|
||||||
|
target: "#chat.guestSession",
|
||||||
|
actions: "startGuestSession",
|
||||||
|
},
|
||||||
ChatNonGuestLogin: {
|
ChatNonGuestLogin: {
|
||||||
target: "#chat.userSession",
|
target: "#chat.userSession",
|
||||||
reenter: true,
|
reenter: true,
|
||||||
|
actions: "startUserSession",
|
||||||
},
|
},
|
||||||
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
|
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
|
||||||
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
|
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
|
||||||
@@ -407,9 +445,11 @@ export const chatMachine = setup({
|
|||||||
// 内容非空;wsConnected=false 同理)
|
// 内容非空;wsConnected=false 同理)
|
||||||
ChatSendMessage: [
|
ChatSendMessage: [
|
||||||
{
|
{
|
||||||
// WS 已连 + 内容非空 → 走 WS(sendingViaWs 等 AI 流式回 reply)
|
// VIP 用户保留 WS;非 VIP 走 HTTP,让后端 daily_limit 能返回 blocked。
|
||||||
guard: ({ context, event }) =>
|
guard: ({ context, event }) =>
|
||||||
context.wsConnected && event.content.trim().length > 0,
|
context.isVip &&
|
||||||
|
context.wsConnected &&
|
||||||
|
event.content.trim().length > 0,
|
||||||
actions: "appendUserMessage",
|
actions: "appendUserMessage",
|
||||||
target: "sendingViaWs",
|
target: "sendingViaWs",
|
||||||
},
|
},
|
||||||
@@ -472,10 +512,9 @@ export const chatMachine = setup({
|
|||||||
}),
|
}),
|
||||||
onDone: {
|
onDone: {
|
||||||
target: "ready",
|
target: "ready",
|
||||||
actions: assign(({ context, event }) => ({
|
actions: assign(({ context, event }) =>
|
||||||
messages: [...context.messages, event.output.reply],
|
applyHttpSendOutput(context, event.output),
|
||||||
isReplyingAI: false,
|
),
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
onError: {
|
onError: {
|
||||||
target: "ready",
|
target: "ready",
|
||||||
|
|||||||
@@ -21,12 +21,17 @@ export interface ChatState {
|
|||||||
* - false:走 HTTP,消耗次数
|
* - false:走 HTTP,消耗次数
|
||||||
*/
|
*/
|
||||||
wsConnected: boolean;
|
wsConnected: boolean;
|
||||||
|
/** 当前非游客用户是否 VIP;游客会话固定 false。 */
|
||||||
|
isVip: boolean;
|
||||||
/** 游客剩余配额(次/天)—— 仅游客业务展示用
|
/** 游客剩余配额(次/天)—— 仅游客业务展示用
|
||||||
*/
|
*/
|
||||||
guestRemainingQuota: number;
|
guestRemainingQuota: number;
|
||||||
/** 游客总配额(仅游客展示用) */
|
/** 游客总配额(仅游客展示用) */
|
||||||
guestTotalQuota: number;
|
guestTotalQuota: number;
|
||||||
quotaExceededTrigger: number;
|
quotaExceededTrigger: number;
|
||||||
|
paywallTriggered: boolean;
|
||||||
|
paywallReason: "daily_limit" | null;
|
||||||
|
paywallDetail: { usedToday: number; limit: number } | null;
|
||||||
isLoadingMore: boolean;
|
isLoadingMore: boolean;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
historyOffset: number;
|
historyOffset: number;
|
||||||
@@ -46,9 +51,13 @@ export const initialState: ChatState = {
|
|||||||
messages: [],
|
messages: [],
|
||||||
isReplyingAI: false,
|
isReplyingAI: false,
|
||||||
wsConnected: false,
|
wsConnected: false,
|
||||||
|
isVip: false,
|
||||||
guestRemainingQuota: 0,
|
guestRemainingQuota: 0,
|
||||||
guestTotalQuota: 0,
|
guestTotalQuota: 0,
|
||||||
quotaExceededTrigger: 0,
|
quotaExceededTrigger: 0,
|
||||||
|
paywallTriggered: false,
|
||||||
|
paywallReason: null,
|
||||||
|
paywallDetail: null,
|
||||||
isLoadingMore: false,
|
isLoadingMore: false,
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
historyOffset: 0,
|
historyOffset: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user