feat(chat): handle credit-gated send responses

This commit is contained in:
2026-06-30 14:17:14 +08:00
parent f4d0f76466
commit 958a0f8ffd
12 changed files with 303 additions and 17 deletions
+20 -2
View File
@@ -84,8 +84,26 @@ export function getChatPaywallSubscriptionUrl(): string {
});
}
export function getChatPaywallNavigationUrl(loginStatus: LoginStatus): string {
const subscriptionUrl = getChatPaywallSubscriptionUrl();
export function getChatCreditsTopUpSubscriptionUrl(): string {
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)) {
return ROUTE_BUILDERS.authWithRedirect(subscriptionUrl);
}
+26 -3
View File
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { useAuthState } from "@/stores/auth/auth-context";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
import { useUserState } from "@/stores/user/user-context";
import { MobileShell } from "@/app/_components/core";
@@ -22,6 +23,7 @@ import {
import {
deriveIsGuest,
getChatPaywallNavigationUrl,
getInsufficientCreditsSubscriptionType,
isChatDevelopmentEnvironment,
openChatInExternalBrowser,
recordExternalBrowserPromptShown,
@@ -34,6 +36,7 @@ export function ChatScreen() {
const state = useChatState();
const chatDispatch = useChatDispatch();
const authState = useAuthState();
const userState = useUserState();
const router = useRouter();
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
useState(false);
@@ -43,8 +46,17 @@ export function ChatScreen() {
// 消息数量限制由后端统一返回 lockDetail,前端不再处理本地额度。
const showMessageLimitBanner =
state.upgradePromptVisible && state.upgradeReason === "weekly_limit";
const messageLimitTitle = "The limit for free chat times\nhas been reached";
state.upgradePromptVisible &&
(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);
@@ -102,7 +114,17 @@ export function ChatScreen() {
}
function handleMessageLimitUnlock(): void {
openChatPaywallSubscription();
const subscriptionType =
state.upgradeReason === "insufficient_credits"
? getInsufficientCreditsSubscriptionType(userState.isVip)
: "vip";
router.push(
getChatPaywallNavigationUrl(
authState.loginStatus,
subscriptionType,
),
);
}
function handleUnlockVoiceMessage(): void {
@@ -137,6 +159,7 @@ export function ChatScreen() {
{showMessageLimitBanner ? (
<ChatQuotaExhaustedBanner
title={messageLimitTitle}
ctaLabel={messageLimitCtaLabel}
onUnlock={handleMessageLimitUnlock}
/>
) : (
+6
View File
@@ -17,6 +17,12 @@ export class ChatSendResponse {
declare readonly timestamp: number;
declare readonly image: ChatImageData;
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) {
const data = ChatSendResponseSchema.parse(input);
+8 -1
View File
@@ -3,17 +3,24 @@
*
*/
import { z } from "zod";
import { booleanOrFalse, stringOrNull } from "../nullable-defaults";
import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads";
export const ChatSendResponseSchema = z.object({
reply: z.string(),
audioUrl: z.string().default(""),
messageId: z.string(),
isGuest: z.boolean().default(false),
isGuest: booleanOrFalse,
// 函数式 default —— 每次 parse 时重新调 Date.now()(后端不返回 timestamp 时用当下时间)
timestamp: z.number().default(() => Date.now()),
image: ChatImageSchema,
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>;
+4 -1
View File
@@ -18,6 +18,9 @@ export const stringOrEmpty = z
.transform((v) => v ?? "")
.default("");
/** `string | null | undefined` → `string | null`(默认 null */
export const stringOrNull = z.string().nullable().default(null);
/** `number | null | undefined` → `number`(默认 0 */
export const numberOrZero = z
.number()
@@ -30,4 +33,4 @@ export const booleanOrFalse = z
.boolean()
.nullable()
.transform((v) => v ?? false)
.default(false);
.default(false);
@@ -38,6 +38,12 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
pendingReplyCount: 1,
upgradePromptVisible: false,
upgradeReason: null,
canSendMessage: true,
cannotSendReason: null,
creditBalance: 0,
creditsCharged: 0,
requiredCredits: 0,
shortfallCredits: 0,
isLoadingMore: false,
hasMore: true,
historyOffset: 0,
@@ -168,6 +174,96 @@ describe("applyHttpSendOutput", () => {
expect(nextState.upgradePromptVisible).toBe(true);
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", () => {
@@ -467,6 +467,11 @@ describe("chatMachine transitions", () => {
limit: 3,
},
},
canSendMessage: false,
cannotSendReason: "insufficient_credits",
creditBalance: 0,
requiredCredits: 2,
shortfallCredits: 2,
}),
reply: null,
},
@@ -474,11 +479,14 @@ describe("chatMachine transitions", () => {
expect(actor.getSnapshot().context.upgradePromptVisible).toBe(true);
expect(actor.getSnapshot().context.upgradeReason).toBe("weekly_limit");
expect(actor.getSnapshot().context.canSendMessage).toBe(false);
actor.send({ type: "ChatPaymentSucceeded" });
expect(actor.getSnapshot().context.upgradePromptVisible).toBe(false);
expect(actor.getSnapshot().context.upgradeReason).toBeNull();
expect(actor.getSnapshot().context.canSendMessage).toBe(true);
expect(actor.getSnapshot().context.cannotSendReason).toBeNull();
actor.stop();
});
+19 -1
View File
@@ -160,8 +160,26 @@ async function sendMessageViaHttp(content: string): Promise<{
result.data.lockDetail.locked &&
result.data.lockDetail.showUpgrade &&
result.data.lockDetail.reason === "weekly_limit";
const isInsufficientCredits =
result.data.canSendMessage === false &&
result.data.cannotSendReason === "insufficient_credits" &&
!hasRenderableSendResponse(result.data);
return {
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"
);
}
+40 -6
View File
@@ -3,7 +3,7 @@ import { getChatRepository } from "@/data/repositories/chat_repository";
import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
import { todayString, Result, Logger } from "@/utils";
import type { ChatState } from "./chat-state";
import type { ChatState, ChatUpgradeReason } from "./chat-state";
const log = new Logger("StoresChatChatMachineHelpers");
@@ -170,29 +170,40 @@ export function applyHttpSendOutput(
const { response, reply } = output;
const lockDetail = response.lockDetail;
const isMessageLimitBlocked =
const isWeeklyLimitBlocked =
lockDetail.locked &&
lockDetail.showUpgrade &&
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 messages =
lastMessage && !lastMessage.isFromAI
!reply && lastMessage && !lastMessage.isFromAI
? context.messages.slice(0, -1)
: context.messages;
return {
messages,
messages: reply ? [...messages, reply] : messages,
...finishPendingReply(context),
upgradePromptVisible: true,
upgradeReason: "weekly_limit",
upgradeReason,
...sendCapabilityState,
};
}
if (!reply) {
return {
...finishPendingReply(context),
...sendCapabilityState,
};
}
@@ -202,6 +213,7 @@ export function applyHttpSendOutput(
...finishPendingReply(context),
upgradePromptVisible: false,
upgradeReason: null,
...sendCapabilityState,
};
}
@@ -210,6 +222,28 @@ export function applyHttpSendOutput(
...finishPendingReply(context),
upgradePromptVisible: false,
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,
};
}
+8 -2
View File
@@ -206,6 +206,10 @@ export const chatMachine = setup({
clearUpgradePrompt: assign(() => ({
upgradePromptVisible: false,
upgradeReason: null,
canSendMessage: true,
cannotSendReason: null,
requiredCredits: 0,
shortfallCredits: 0,
})),
markPaymentUnlockPending: assign(() => ({
@@ -306,7 +310,8 @@ export const chatMachine = setup({
on: {
ChatSendMessage: {
actions: ["appendGuestUserMessage", "enqueueMessage"],
guard: ({ event }) => event.content.trim().length > 0,
guard: ({ context, event }) =>
context.canSendMessage && event.content.trim().length > 0,
},
ChatSendImage: {
actions: "appendGuestUserImage",
@@ -441,7 +446,8 @@ export const chatMachine = setup({
ready: {
on: {
ChatSendMessage: {
guard: ({ event }) => event.content.trim().length > 0,
guard: ({ context, event }) =>
context.canSendMessage && event.content.trim().length > 0,
actions: ["appendUserMessage", "enqueueMessage"],
},
ChatSendImage: {
+15 -1
View File
@@ -1,11 +1,19 @@
import type { UiMessage } from "@/data/dto/chat";
export type ChatUpgradeReason = "weekly_limit" | "insufficient_credits";
export interface ChatState {
messages: UiMessage[];
isReplyingAI: boolean;
pendingReplyCount: number;
upgradePromptVisible: boolean;
upgradeReason: "weekly_limit" | null;
upgradeReason: ChatUpgradeReason | null;
canSendMessage: boolean;
cannotSendReason: string | null;
creditBalance: number;
creditsCharged: number;
requiredCredits: number;
shortfallCredits: number;
isLoadingMore: boolean;
hasMore: boolean;
historyOffset: number;
@@ -27,6 +35,12 @@ export const initialState: ChatState = {
pendingReplyCount: 0,
upgradePromptVisible: false,
upgradeReason: null,
canSendMessage: true,
cannotSendReason: null,
creditBalance: 0,
creditsCharged: 0,
requiredCredits: 0,
shortfallCredits: 0,
isLoadingMore: false,
hasMore: true,
historyOffset: 0,