feat(chat): handle credit-gated send responses
This commit is contained in:
@@ -0,0 +1,53 @@
|
|||||||
|
这次新增/补齐的主要是这些字段,都是给前端判断“能不能继续发、当前内容锁不锁、扣了多少积分”用的。
|
||||||
|
{
|
||||||
|
"canSendMessage": false,
|
||||||
|
"cannotSendReason": "insufficient_credits",
|
||||||
|
"creditBalance": 0,
|
||||||
|
"creditsCharged": 0,
|
||||||
|
"requiredCredits": 2,
|
||||||
|
"shortfallCredits": 2
|
||||||
|
}
|
||||||
|
字段含义
|
||||||
|
canSendMessage
|
||||||
|
用户是否还能继续发普通聊天消息。
|
||||||
|
如果是 false,前端应该禁用输入框/发送按钮,提示充值。
|
||||||
|
cannotSendReason
|
||||||
|
不能继续发送消息的原因。
|
||||||
|
目前主要是:
|
||||||
|
insufficient_credits
|
||||||
|
表示免费聊天次数用完,并且积分也不够继续发消息。
|
||||||
|
creditBalance
|
||||||
|
本次接口处理后的用户积分余额。
|
||||||
|
比如原来 100,发了一条会员普通消息扣 2,这里返回 98。
|
||||||
|
creditsCharged
|
||||||
|
本次实际扣掉的积分。
|
||||||
|
例如:
|
||||||
|
普通聊天扣 2
|
||||||
|
照片扣 40
|
||||||
|
语音消息扣 20
|
||||||
|
私密消息扣 10
|
||||||
|
没有扣费就是 0
|
||||||
|
requiredCredits
|
||||||
|
本次动作理论上需要多少积分。
|
||||||
|
比如用户想看照片,需要 40,就返回 40。
|
||||||
|
如果免费额度内,通常是 0。
|
||||||
|
shortfallCredits
|
||||||
|
用户还差多少积分。
|
||||||
|
比如照片需要 40,用户只有 15,则:
|
||||||
|
{
|
||||||
|
"requiredCredits": 40,
|
||||||
|
"creditBalance": 15,
|
||||||
|
"shortfallCredits": 25
|
||||||
|
}
|
||||||
|
和旧字段的关系
|
||||||
|
blocked
|
||||||
|
当前这条回复/内容是否被锁住。比如照片、语音、私密消息积分不够时是 true。
|
||||||
|
blockReason
|
||||||
|
当前内容被锁的原因,比如:
|
||||||
|
insufficient_credits
|
||||||
|
image_paywall
|
||||||
|
voice_message
|
||||||
|
private_message
|
||||||
|
lockDetail
|
||||||
|
给前端展示锁定卡片/充值提示的详细信息。
|
||||||
|
里面也会带 requiredCredits/currentCredits/shortfallCredits,方便前端直接渲染弹窗。
|
||||||
@@ -84,8 +84,26 @@ export function getChatPaywallSubscriptionUrl(): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getChatPaywallNavigationUrl(loginStatus: LoginStatus): string {
|
export function getChatCreditsTopUpSubscriptionUrl(): string {
|
||||||
const subscriptionUrl = getChatPaywallSubscriptionUrl();
|
return ROUTE_BUILDERS.subscription("topup", {
|
||||||
|
returnTo: "chat",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInsufficientCreditsSubscriptionType(
|
||||||
|
isVip: boolean,
|
||||||
|
): "vip" | "topup" {
|
||||||
|
return isVip ? "topup" : "vip";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChatPaywallNavigationUrl(
|
||||||
|
loginStatus: LoginStatus,
|
||||||
|
type: "vip" | "topup" = "vip",
|
||||||
|
): string {
|
||||||
|
const subscriptionUrl =
|
||||||
|
type === "topup"
|
||||||
|
? getChatCreditsTopUpSubscriptionUrl()
|
||||||
|
: getChatPaywallSubscriptionUrl();
|
||||||
if (deriveIsGuest(loginStatus)) {
|
if (deriveIsGuest(loginStatus)) {
|
||||||
return ROUTE_BUILDERS.authWithRedirect(subscriptionUrl);
|
return ROUTE_BUILDERS.authWithRedirect(subscriptionUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
|
|||||||
|
|
||||||
import { useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthState } from "@/stores/auth/auth-context";
|
||||||
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
|
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
|
||||||
|
import { useUserState } from "@/stores/user/user-context";
|
||||||
|
|
||||||
import { MobileShell } from "@/app/_components/core";
|
import { MobileShell } from "@/app/_components/core";
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
deriveIsGuest,
|
deriveIsGuest,
|
||||||
getChatPaywallNavigationUrl,
|
getChatPaywallNavigationUrl,
|
||||||
|
getInsufficientCreditsSubscriptionType,
|
||||||
isChatDevelopmentEnvironment,
|
isChatDevelopmentEnvironment,
|
||||||
openChatInExternalBrowser,
|
openChatInExternalBrowser,
|
||||||
recordExternalBrowserPromptShown,
|
recordExternalBrowserPromptShown,
|
||||||
@@ -34,6 +36,7 @@ export function ChatScreen() {
|
|||||||
const state = useChatState();
|
const state = useChatState();
|
||||||
const chatDispatch = useChatDispatch();
|
const chatDispatch = useChatDispatch();
|
||||||
const authState = useAuthState();
|
const authState = useAuthState();
|
||||||
|
const userState = useUserState();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
|
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@@ -43,8 +46,17 @@ export function ChatScreen() {
|
|||||||
|
|
||||||
// 消息数量限制由后端统一返回 lockDetail,前端不再处理本地额度。
|
// 消息数量限制由后端统一返回 lockDetail,前端不再处理本地额度。
|
||||||
const showMessageLimitBanner =
|
const showMessageLimitBanner =
|
||||||
state.upgradePromptVisible && state.upgradeReason === "weekly_limit";
|
state.upgradePromptVisible &&
|
||||||
const messageLimitTitle = "The limit for free chat times\nhas been reached";
|
(state.upgradeReason === "weekly_limit" ||
|
||||||
|
state.upgradeReason === "insufficient_credits");
|
||||||
|
const messageLimitTitle =
|
||||||
|
state.upgradeReason === "insufficient_credits"
|
||||||
|
? "Insufficient credits\nTop up to continue chatting"
|
||||||
|
: "The limit for free chat times\nhas been reached";
|
||||||
|
const messageLimitCtaLabel =
|
||||||
|
state.upgradeReason === "insufficient_credits"
|
||||||
|
? "Top up credits to continue"
|
||||||
|
: "Unlock your membership to continue";
|
||||||
|
|
||||||
const externalBrowserPromptShownRef = useRef(false);
|
const externalBrowserPromptShownRef = useRef(false);
|
||||||
|
|
||||||
@@ -102,7 +114,17 @@ export function ChatScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleMessageLimitUnlock(): void {
|
function handleMessageLimitUnlock(): void {
|
||||||
openChatPaywallSubscription();
|
const subscriptionType =
|
||||||
|
state.upgradeReason === "insufficient_credits"
|
||||||
|
? getInsufficientCreditsSubscriptionType(userState.isVip)
|
||||||
|
: "vip";
|
||||||
|
|
||||||
|
router.push(
|
||||||
|
getChatPaywallNavigationUrl(
|
||||||
|
authState.loginStatus,
|
||||||
|
subscriptionType,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUnlockVoiceMessage(): void {
|
function handleUnlockVoiceMessage(): void {
|
||||||
@@ -137,6 +159,7 @@ export function ChatScreen() {
|
|||||||
{showMessageLimitBanner ? (
|
{showMessageLimitBanner ? (
|
||||||
<ChatQuotaExhaustedBanner
|
<ChatQuotaExhaustedBanner
|
||||||
title={messageLimitTitle}
|
title={messageLimitTitle}
|
||||||
|
ctaLabel={messageLimitCtaLabel}
|
||||||
onUnlock={handleMessageLimitUnlock}
|
onUnlock={handleMessageLimitUnlock}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ export class ChatSendResponse {
|
|||||||
declare readonly timestamp: number;
|
declare readonly timestamp: number;
|
||||||
declare readonly image: ChatImageData;
|
declare readonly image: ChatImageData;
|
||||||
declare readonly lockDetail: ChatLockDetailData;
|
declare readonly lockDetail: ChatLockDetailData;
|
||||||
|
declare readonly canSendMessage: boolean;
|
||||||
|
declare readonly cannotSendReason: string | null;
|
||||||
|
declare readonly creditBalance: number;
|
||||||
|
declare readonly creditsCharged: number;
|
||||||
|
declare readonly requiredCredits: number;
|
||||||
|
declare readonly shortfallCredits: number;
|
||||||
|
|
||||||
private constructor(input: ChatSendResponseInput) {
|
private constructor(input: ChatSendResponseInput) {
|
||||||
const data = ChatSendResponseSchema.parse(input);
|
const data = ChatSendResponseSchema.parse(input);
|
||||||
|
|||||||
@@ -3,17 +3,24 @@
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { booleanOrFalse, stringOrNull } from "../nullable-defaults";
|
||||||
import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads";
|
import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads";
|
||||||
|
|
||||||
export const ChatSendResponseSchema = z.object({
|
export const ChatSendResponseSchema = z.object({
|
||||||
reply: z.string(),
|
reply: z.string(),
|
||||||
audioUrl: z.string().default(""),
|
audioUrl: z.string().default(""),
|
||||||
messageId: z.string(),
|
messageId: z.string(),
|
||||||
isGuest: z.boolean().default(false),
|
isGuest: booleanOrFalse,
|
||||||
// 函数式 default —— 每次 parse 时重新调 Date.now()(后端不返回 timestamp 时用当下时间)
|
// 函数式 default —— 每次 parse 时重新调 Date.now()(后端不返回 timestamp 时用当下时间)
|
||||||
timestamp: z.number().default(() => Date.now()),
|
timestamp: z.number().default(() => Date.now()),
|
||||||
image: ChatImageSchema,
|
image: ChatImageSchema,
|
||||||
lockDetail: ChatLockDetailSchema,
|
lockDetail: ChatLockDetailSchema,
|
||||||
|
canSendMessage: z.boolean().default(true),
|
||||||
|
cannotSendReason: stringOrNull,
|
||||||
|
creditBalance: z.number().default(0),
|
||||||
|
creditsCharged: z.number().default(0),
|
||||||
|
requiredCredits: z.number().default(0),
|
||||||
|
shortfallCredits: z.number().default(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ChatSendResponseInput = z.input<typeof ChatSendResponseSchema>;
|
export type ChatSendResponseInput = z.input<typeof ChatSendResponseSchema>;
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export const stringOrEmpty = z
|
|||||||
.transform((v) => v ?? "")
|
.transform((v) => v ?? "")
|
||||||
.default("");
|
.default("");
|
||||||
|
|
||||||
|
/** `string | null | undefined` → `string | null`(默认 null) */
|
||||||
|
export const stringOrNull = z.string().nullable().default(null);
|
||||||
|
|
||||||
/** `number | null | undefined` → `number`(默认 0) */
|
/** `number | null | undefined` → `number`(默认 0) */
|
||||||
export const numberOrZero = z
|
export const numberOrZero = z
|
||||||
.number()
|
.number()
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
|||||||
pendingReplyCount: 1,
|
pendingReplyCount: 1,
|
||||||
upgradePromptVisible: false,
|
upgradePromptVisible: false,
|
||||||
upgradeReason: null,
|
upgradeReason: null,
|
||||||
|
canSendMessage: true,
|
||||||
|
cannotSendReason: null,
|
||||||
|
creditBalance: 0,
|
||||||
|
creditsCharged: 0,
|
||||||
|
requiredCredits: 0,
|
||||||
|
shortfallCredits: 0,
|
||||||
isLoadingMore: false,
|
isLoadingMore: false,
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
historyOffset: 0,
|
historyOffset: 0,
|
||||||
@@ -168,6 +174,96 @@ describe("applyHttpSendOutput", () => {
|
|||||||
expect(nextState.upgradePromptVisible).toBe(true);
|
expect(nextState.upgradePromptVisible).toBe(true);
|
||||||
expect(nextState.upgradeReason).toBe("weekly_limit");
|
expect(nextState.upgradeReason).toBe("weekly_limit");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stores insufficient credit state and removes failed optimistic messages", () => {
|
||||||
|
const context = makeChatState({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
content: "hello",
|
||||||
|
isFromAI: false,
|
||||||
|
date: "2026-06-25",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextState = applyHttpSendOutput(context, {
|
||||||
|
response: makeResponse({
|
||||||
|
reply: "",
|
||||||
|
messageId: "",
|
||||||
|
canSendMessage: false,
|
||||||
|
cannotSendReason: "insufficient_credits",
|
||||||
|
creditBalance: 0,
|
||||||
|
creditsCharged: 0,
|
||||||
|
requiredCredits: 2,
|
||||||
|
shortfallCredits: 2,
|
||||||
|
lockDetail: {
|
||||||
|
locked: true,
|
||||||
|
showContent: false,
|
||||||
|
showUpgrade: true,
|
||||||
|
reason: "insufficient_credits",
|
||||||
|
hint: "Insufficient credits.",
|
||||||
|
detail: {
|
||||||
|
requiredCredits: 2,
|
||||||
|
currentCredits: 0,
|
||||||
|
shortfallCredits: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
reply: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(nextState.messages).toEqual([]);
|
||||||
|
expect(nextState.isReplyingAI).toBe(false);
|
||||||
|
expect(nextState.upgradePromptVisible).toBe(true);
|
||||||
|
expect(nextState.upgradeReason).toBe("insufficient_credits");
|
||||||
|
expect(nextState.canSendMessage).toBe(false);
|
||||||
|
expect(nextState.cannotSendReason).toBe("insufficient_credits");
|
||||||
|
expect(nextState.requiredCredits).toBe(2);
|
||||||
|
expect(nextState.shortfallCredits).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a valid reply while marking future sends as unavailable", () => {
|
||||||
|
const context = makeChatState({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
content: "hello",
|
||||||
|
isFromAI: false,
|
||||||
|
date: "2026-06-25",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const reply = sendResponseToUiMessage(
|
||||||
|
makeResponse({
|
||||||
|
reply: "This one went through, but you need credits next.",
|
||||||
|
canSendMessage: false,
|
||||||
|
cannotSendReason: "insufficient_credits",
|
||||||
|
creditBalance: 0,
|
||||||
|
requiredCredits: 2,
|
||||||
|
shortfallCredits: 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextState = applyHttpSendOutput(context, {
|
||||||
|
response: makeResponse({
|
||||||
|
reply: "This one went through, but you need credits next.",
|
||||||
|
canSendMessage: false,
|
||||||
|
cannotSendReason: "insufficient_credits",
|
||||||
|
creditBalance: 0,
|
||||||
|
requiredCredits: 2,
|
||||||
|
shortfallCredits: 2,
|
||||||
|
}),
|
||||||
|
reply,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(nextState.messages).toHaveLength(2);
|
||||||
|
expect(nextState.messages?.[1]).toMatchObject({
|
||||||
|
content: "This one went through, but you need credits next.",
|
||||||
|
isFromAI: true,
|
||||||
|
});
|
||||||
|
expect(nextState.upgradePromptVisible).toBe(true);
|
||||||
|
expect(nextState.upgradeReason).toBe("insufficient_credits");
|
||||||
|
expect(nextState.canSendMessage).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("localMessagesToUi", () => {
|
describe("localMessagesToUi", () => {
|
||||||
|
|||||||
@@ -467,6 +467,11 @@ describe("chatMachine transitions", () => {
|
|||||||
limit: 3,
|
limit: 3,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
canSendMessage: false,
|
||||||
|
cannotSendReason: "insufficient_credits",
|
||||||
|
creditBalance: 0,
|
||||||
|
requiredCredits: 2,
|
||||||
|
shortfallCredits: 2,
|
||||||
}),
|
}),
|
||||||
reply: null,
|
reply: null,
|
||||||
},
|
},
|
||||||
@@ -474,11 +479,14 @@ describe("chatMachine transitions", () => {
|
|||||||
|
|
||||||
expect(actor.getSnapshot().context.upgradePromptVisible).toBe(true);
|
expect(actor.getSnapshot().context.upgradePromptVisible).toBe(true);
|
||||||
expect(actor.getSnapshot().context.upgradeReason).toBe("weekly_limit");
|
expect(actor.getSnapshot().context.upgradeReason).toBe("weekly_limit");
|
||||||
|
expect(actor.getSnapshot().context.canSendMessage).toBe(false);
|
||||||
|
|
||||||
actor.send({ type: "ChatPaymentSucceeded" });
|
actor.send({ type: "ChatPaymentSucceeded" });
|
||||||
|
|
||||||
expect(actor.getSnapshot().context.upgradePromptVisible).toBe(false);
|
expect(actor.getSnapshot().context.upgradePromptVisible).toBe(false);
|
||||||
expect(actor.getSnapshot().context.upgradeReason).toBeNull();
|
expect(actor.getSnapshot().context.upgradeReason).toBeNull();
|
||||||
|
expect(actor.getSnapshot().context.canSendMessage).toBe(true);
|
||||||
|
expect(actor.getSnapshot().context.cannotSendReason).toBeNull();
|
||||||
|
|
||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -160,8 +160,26 @@ async function sendMessageViaHttp(content: string): Promise<{
|
|||||||
result.data.lockDetail.locked &&
|
result.data.lockDetail.locked &&
|
||||||
result.data.lockDetail.showUpgrade &&
|
result.data.lockDetail.showUpgrade &&
|
||||||
result.data.lockDetail.reason === "weekly_limit";
|
result.data.lockDetail.reason === "weekly_limit";
|
||||||
|
const isInsufficientCredits =
|
||||||
|
result.data.canSendMessage === false &&
|
||||||
|
result.data.cannotSendReason === "insufficient_credits" &&
|
||||||
|
!hasRenderableSendResponse(result.data);
|
||||||
return {
|
return {
|
||||||
response: result.data,
|
response: result.data,
|
||||||
reply: isMessageLimit ? null : sendResponseToUiMessage(result.data),
|
reply:
|
||||||
|
isMessageLimit || isInsufficientCredits
|
||||||
|
? null
|
||||||
|
: sendResponseToUiMessage(result.data),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasRenderableSendResponse(response: ChatSendResponse): boolean {
|
||||||
|
return (
|
||||||
|
response.reply.trim().length > 0 ||
|
||||||
|
response.audioUrl.trim().length > 0 ||
|
||||||
|
Boolean(response.image.url) ||
|
||||||
|
response.lockDetail.reason === "private_message" ||
|
||||||
|
response.lockDetail.reason === "voice_message" ||
|
||||||
|
response.lockDetail.reason === "image_paywall"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getChatRepository } from "@/data/repositories/chat_repository";
|
|||||||
import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
|
import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
|
||||||
import { todayString, Result, Logger } from "@/utils";
|
import { todayString, Result, Logger } from "@/utils";
|
||||||
|
|
||||||
import type { ChatState } from "./chat-state";
|
import type { ChatState, ChatUpgradeReason } from "./chat-state";
|
||||||
|
|
||||||
const log = new Logger("StoresChatChatMachineHelpers");
|
const log = new Logger("StoresChatChatMachineHelpers");
|
||||||
|
|
||||||
@@ -170,29 +170,40 @@ export function applyHttpSendOutput(
|
|||||||
const { response, reply } = output;
|
const { response, reply } = output;
|
||||||
|
|
||||||
const lockDetail = response.lockDetail;
|
const lockDetail = response.lockDetail;
|
||||||
const isMessageLimitBlocked =
|
const isWeeklyLimitBlocked =
|
||||||
lockDetail.locked &&
|
lockDetail.locked &&
|
||||||
lockDetail.showUpgrade &&
|
lockDetail.showUpgrade &&
|
||||||
lockDetail.reason === "weekly_limit";
|
lockDetail.reason === "weekly_limit";
|
||||||
|
const isInsufficientCredits =
|
||||||
|
response.canSendMessage === false &&
|
||||||
|
response.cannotSendReason === "insufficient_credits";
|
||||||
|
const upgradeReason: ChatUpgradeReason | null = isWeeklyLimitBlocked
|
||||||
|
? "weekly_limit"
|
||||||
|
: isInsufficientCredits
|
||||||
|
? "insufficient_credits"
|
||||||
|
: null;
|
||||||
|
const sendCapabilityState = getSendCapabilityState(response);
|
||||||
|
|
||||||
if (isMessageLimitBlocked) {
|
if (upgradeReason) {
|
||||||
const lastMessage = context.messages[context.messages.length - 1];
|
const lastMessage = context.messages[context.messages.length - 1];
|
||||||
const messages =
|
const messages =
|
||||||
lastMessage && !lastMessage.isFromAI
|
!reply && lastMessage && !lastMessage.isFromAI
|
||||||
? context.messages.slice(0, -1)
|
? context.messages.slice(0, -1)
|
||||||
: context.messages;
|
: context.messages;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages: reply ? [...messages, reply] : messages,
|
||||||
...finishPendingReply(context),
|
...finishPendingReply(context),
|
||||||
upgradePromptVisible: true,
|
upgradePromptVisible: true,
|
||||||
upgradeReason: "weekly_limit",
|
upgradeReason,
|
||||||
|
...sendCapabilityState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!reply) {
|
if (!reply) {
|
||||||
return {
|
return {
|
||||||
...finishPendingReply(context),
|
...finishPendingReply(context),
|
||||||
|
...sendCapabilityState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +213,7 @@ export function applyHttpSendOutput(
|
|||||||
...finishPendingReply(context),
|
...finishPendingReply(context),
|
||||||
upgradePromptVisible: false,
|
upgradePromptVisible: false,
|
||||||
upgradeReason: null,
|
upgradeReason: null,
|
||||||
|
...sendCapabilityState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,6 +222,28 @@ export function applyHttpSendOutput(
|
|||||||
...finishPendingReply(context),
|
...finishPendingReply(context),
|
||||||
upgradePromptVisible: false,
|
upgradePromptVisible: false,
|
||||||
upgradeReason: null,
|
upgradeReason: null,
|
||||||
|
...sendCapabilityState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSendCapabilityState(
|
||||||
|
response: ChatSendResponse,
|
||||||
|
): Pick<
|
||||||
|
ChatState,
|
||||||
|
| "canSendMessage"
|
||||||
|
| "cannotSendReason"
|
||||||
|
| "creditBalance"
|
||||||
|
| "creditsCharged"
|
||||||
|
| "requiredCredits"
|
||||||
|
| "shortfallCredits"
|
||||||
|
> {
|
||||||
|
return {
|
||||||
|
canSendMessage: response.canSendMessage,
|
||||||
|
cannotSendReason: response.cannotSendReason,
|
||||||
|
creditBalance: response.creditBalance,
|
||||||
|
creditsCharged: response.creditsCharged,
|
||||||
|
requiredCredits: response.requiredCredits,
|
||||||
|
shortfallCredits: response.shortfallCredits,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -206,6 +206,10 @@ export const chatMachine = setup({
|
|||||||
clearUpgradePrompt: assign(() => ({
|
clearUpgradePrompt: assign(() => ({
|
||||||
upgradePromptVisible: false,
|
upgradePromptVisible: false,
|
||||||
upgradeReason: null,
|
upgradeReason: null,
|
||||||
|
canSendMessage: true,
|
||||||
|
cannotSendReason: null,
|
||||||
|
requiredCredits: 0,
|
||||||
|
shortfallCredits: 0,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
markPaymentUnlockPending: assign(() => ({
|
markPaymentUnlockPending: assign(() => ({
|
||||||
@@ -306,7 +310,8 @@ export const chatMachine = setup({
|
|||||||
on: {
|
on: {
|
||||||
ChatSendMessage: {
|
ChatSendMessage: {
|
||||||
actions: ["appendGuestUserMessage", "enqueueMessage"],
|
actions: ["appendGuestUserMessage", "enqueueMessage"],
|
||||||
guard: ({ event }) => event.content.trim().length > 0,
|
guard: ({ context, event }) =>
|
||||||
|
context.canSendMessage && event.content.trim().length > 0,
|
||||||
},
|
},
|
||||||
ChatSendImage: {
|
ChatSendImage: {
|
||||||
actions: "appendGuestUserImage",
|
actions: "appendGuestUserImage",
|
||||||
@@ -441,7 +446,8 @@ export const chatMachine = setup({
|
|||||||
ready: {
|
ready: {
|
||||||
on: {
|
on: {
|
||||||
ChatSendMessage: {
|
ChatSendMessage: {
|
||||||
guard: ({ event }) => event.content.trim().length > 0,
|
guard: ({ context, event }) =>
|
||||||
|
context.canSendMessage && event.content.trim().length > 0,
|
||||||
actions: ["appendUserMessage", "enqueueMessage"],
|
actions: ["appendUserMessage", "enqueueMessage"],
|
||||||
},
|
},
|
||||||
ChatSendImage: {
|
ChatSendImage: {
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import type { UiMessage } from "@/data/dto/chat";
|
import type { UiMessage } from "@/data/dto/chat";
|
||||||
|
|
||||||
|
export type ChatUpgradeReason = "weekly_limit" | "insufficient_credits";
|
||||||
|
|
||||||
export interface ChatState {
|
export interface ChatState {
|
||||||
messages: UiMessage[];
|
messages: UiMessage[];
|
||||||
isReplyingAI: boolean;
|
isReplyingAI: boolean;
|
||||||
pendingReplyCount: number;
|
pendingReplyCount: number;
|
||||||
upgradePromptVisible: boolean;
|
upgradePromptVisible: boolean;
|
||||||
upgradeReason: "weekly_limit" | null;
|
upgradeReason: ChatUpgradeReason | null;
|
||||||
|
canSendMessage: boolean;
|
||||||
|
cannotSendReason: string | null;
|
||||||
|
creditBalance: number;
|
||||||
|
creditsCharged: number;
|
||||||
|
requiredCredits: number;
|
||||||
|
shortfallCredits: number;
|
||||||
isLoadingMore: boolean;
|
isLoadingMore: boolean;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
historyOffset: number;
|
historyOffset: number;
|
||||||
@@ -27,6 +35,12 @@ export const initialState: ChatState = {
|
|||||||
pendingReplyCount: 0,
|
pendingReplyCount: 0,
|
||||||
upgradePromptVisible: false,
|
upgradePromptVisible: false,
|
||||||
upgradeReason: null,
|
upgradeReason: null,
|
||||||
|
canSendMessage: true,
|
||||||
|
cannotSendReason: null,
|
||||||
|
creditBalance: 0,
|
||||||
|
creditsCharged: 0,
|
||||||
|
requiredCredits: 0,
|
||||||
|
shortfallCredits: 0,
|
||||||
isLoadingMore: false,
|
isLoadingMore: false,
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
historyOffset: 0,
|
historyOffset: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user