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
@@ -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,