diff --git a/docs/backend/send.md b/docs/backend/send.md
new file mode 100644
index 00000000..54439e48
--- /dev/null
+++ b/docs/backend/send.md
@@ -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,方便前端直接渲染弹窗。
\ No newline at end of file
diff --git a/src/app/chat/chat-screen.helpers.ts b/src/app/chat/chat-screen.helpers.ts
index 4c65f06c..5bcc942b 100644
--- a/src/app/chat/chat-screen.helpers.ts
+++ b/src/app/chat/chat-screen.helpers.ts
@@ -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);
}
diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx
index c68eda23..107fe12e 100644
--- a/src/app/chat/chat-screen.tsx
+++ b/src/app/chat/chat-screen.tsx
@@ -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 ? (
) : (
diff --git a/src/data/dto/chat/chat_send_response.ts b/src/data/dto/chat/chat_send_response.ts
index 69269cf5..eac4b79b 100644
--- a/src/data/dto/chat/chat_send_response.ts
+++ b/src/data/dto/chat/chat_send_response.ts
@@ -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);
diff --git a/src/data/schemas/chat/chat_send_response.ts b/src/data/schemas/chat/chat_send_response.ts
index 438b0d95..b0c35a29 100644
--- a/src/data/schemas/chat/chat_send_response.ts
+++ b/src/data/schemas/chat/chat_send_response.ts
@@ -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;
diff --git a/src/data/schemas/nullable-defaults.ts b/src/data/schemas/nullable-defaults.ts
index 4d9df94b..aa205769 100644
--- a/src/data/schemas/nullable-defaults.ts
+++ b/src/data/schemas/nullable-defaults.ts
@@ -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);
\ No newline at end of file
+ .default(false);
diff --git a/src/stores/chat/__tests__/chat-machine.helpers.test.ts b/src/stores/chat/__tests__/chat-machine.helpers.test.ts
index 80de88f6..38ea1619 100644
--- a/src/stores/chat/__tests__/chat-machine.helpers.test.ts
+++ b/src/stores/chat/__tests__/chat-machine.helpers.test.ts
@@ -38,6 +38,12 @@ function makeChatState(overrides: Partial = {}): 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", () => {
diff --git a/src/stores/chat/__tests__/chat-machine.transitions.test.ts b/src/stores/chat/__tests__/chat-machine.transitions.test.ts
index 3b3a690d..8fb4474a 100644
--- a/src/stores/chat/__tests__/chat-machine.transitions.test.ts
+++ b/src/stores/chat/__tests__/chat-machine.transitions.test.ts
@@ -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();
});
diff --git a/src/stores/chat/chat-machine.actors.ts b/src/stores/chat/chat-machine.actors.ts
index 751e599f..31ba1648 100644
--- a/src/stores/chat/chat-machine.actors.ts
+++ b/src/stores/chat/chat-machine.actors.ts
@@ -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"
+ );
+}
diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts
index d5269110..5bc73a15 100644
--- a/src/stores/chat/chat-machine.helpers.ts
+++ b/src/stores/chat/chat-machine.helpers.ts
@@ -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,
};
}
diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts
index 9bc10dd0..1022729f 100644
--- a/src/stores/chat/chat-machine.ts
+++ b/src/stores/chat/chat-machine.ts
@@ -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: {
diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts
index 3c2ed108..588baa21 100644
--- a/src/stores/chat/chat-state.ts
+++ b/src/stores/chat/chat-state.ts
@@ -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,