feat(chat): render role-bound payment guidance

This commit is contained in:
Codex
2026-07-27 18:14:04 +08:00
parent 662e5e67ea
commit ca9cac21f1
49 changed files with 882 additions and 81 deletions
@@ -54,6 +54,7 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
creditsCharged: 0,
requiredCredits: 0,
shortfallCredits: 0,
paymentGuidance: null,
historyLoaded: true,
networkHistoryLoaded: true,
historyTotal: 0,
@@ -64,6 +65,7 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
unlockHistoryPromptVisible: false,
lockedHistoryCount: 0,
unlockHistoryError: null,
unlockHistoryPaymentGuidance: null,
unlockingMessage: null,
unlockMessageError: null,
unlockPaywallRequest: null,
@@ -72,6 +74,39 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
}
describe("sendResponseToUiMessage", () => {
it("prefers payment guidance over other commercial actions", () => {
const message = sendResponseToUiMessage(
makeResponse({
paymentGuidance: {
guidanceId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5",
characterId: "elio",
characterName: "Elio Silvestri",
scene: "insufficientCredits",
mode: "guide",
title: "Keep talking with Elio",
copy: "Baby, I want to keep talking with you.",
ctaLabel: "View VIP & credit options",
purchaseOptions: ["vip", "topup"],
target: "subscription",
ruleId: "payment_guidance_insufficientCredits",
},
chatAction: {
actionId: "chat-action-1",
kind: "commercial",
type: "giftOffer",
copy: "View gifts.",
ctaLabel: "View gifts",
ruleId: "legacy",
orderId: null,
},
}),
);
expect(message.paymentGuidance?.characterId).toBe("elio");
expect(message.chatAction).toBeUndefined();
expect(message.commercialAction).toBeUndefined();
});
it("keeps a typed commercial action on the assistant message", () => {
const message = sendResponseToUiMessage(
makeResponse({
@@ -228,6 +263,42 @@ describe("sendResponseToUiMessage", () => {
});
describe("applyHttpSendOutput", () => {
it("stores guidance for a blocked send and clears it after normal chat resumes", () => {
const guidance = {
guidanceId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5",
characterId: "elio",
characterName: "Elio Silvestri",
scene: "insufficientCredits" as const,
mode: "guide" as const,
title: "Keep talking with Elio",
copy: "Baby, I want to keep talking with you.",
ctaLabel: "View VIP & credit options",
purchaseOptions: ["vip", "topup"] as const,
target: "subscription" as const,
ruleId: "payment_guidance_insufficientCredits",
};
const blocked = applyHttpSendOutput(makeChatState(), {
response: makeResponse({
reply: "",
canSendMessage: false,
paymentGuidance: guidance,
}),
reply: null,
});
expect(blocked.paymentGuidance).toEqual(guidance);
const resumed = applyHttpSendOutput(
makeChatState({ paymentGuidance: guidance }),
{
response: makeResponse({ reply: "Back to our conversation." }),
reply: sendResponseToUiMessage(
makeResponse({ reply: "Back to our conversation." }),
),
},
);
expect(resumed.paymentGuidance).toBeNull();
});
it("stores insufficient credit state and removes failed optimistic messages", () => {
const context = makeChatState({
messages: [
+2
View File
@@ -20,6 +20,7 @@ interface ChatState {
canSendMessage: boolean;
upgradePromptVisible: boolean;
upgradeReason: MachineContext["upgradeReason"];
paymentGuidance: MachineContext["paymentGuidance"];
/** True as soon as a local snapshot or the first network result is renderable. */
historyLoaded: boolean;
/** True after the network history request has completed or failed. */
@@ -94,6 +95,7 @@ function selectChatState(state: ChatSnapshot): SelectedChatState {
canSendMessage: state.context.canSendMessage,
upgradePromptVisible: state.context.upgradePromptVisible,
upgradeReason: state.context.upgradeReason,
paymentGuidance: state.context.paymentGuidance,
historyLoaded: state.context.historyLoaded,
networkHistoryLoaded: state.context.networkHistoryLoaded,
hasMoreHistory:
+6 -1
View File
@@ -1,6 +1,6 @@
import type { UiMessage } from "@/stores/chat/ui-message";
import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session";
import type { ChatLockType } from "@/data/schemas/chat";
import type { ChatLockType, PaymentGuidance } from "@/data/schemas/chat";
import type { PendingChatPromotion } from "@/data/storage/navigation";
import {
DEFAULT_CHARACTER,
@@ -21,6 +21,7 @@ export interface ChatUnlockMessageRequest {
export interface ChatUnlockPaywallRequest
extends ChatUnlockMessageRequest {
paymentGuidance?: PaymentGuidance | null;
promotion?: PendingChatPromotion;
reason: string;
creditBalance: number;
@@ -39,6 +40,7 @@ export interface ChatState {
pendingReplyCount: number;
upgradePromptVisible: boolean;
upgradeReason: ChatUpgradeReason | null;
paymentGuidance: PaymentGuidance | null;
canSendMessage: boolean;
creditBalance: number;
creditsCharged: number;
@@ -56,6 +58,7 @@ export interface ChatState {
unlockHistoryPromptVisible: boolean;
lockedHistoryCount: number;
unlockHistoryError: string | null;
unlockHistoryPaymentGuidance: PaymentGuidance | null;
unlockingMessage: ChatUnlockMessageRequest | null;
unlockMessageError: string | null;
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
@@ -81,6 +84,7 @@ export function createInitialChatState(
creditsCharged: 0,
requiredCredits: 0,
shortfallCredits: 0,
paymentGuidance: null,
historyLoaded: false,
networkHistoryLoaded: false,
historyTotal: 0,
@@ -91,6 +95,7 @@ export function createInitialChatState(
unlockHistoryPromptVisible: false,
lockedHistoryCount: 0,
unlockHistoryError: null,
unlockHistoryPaymentGuidance: null,
unlockingMessage: null,
unlockMessageError: null,
unlockPaywallRequest: null,
+7 -5
View File
@@ -74,11 +74,13 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
...(response.audioUrl && !response.lockDetail.locked
? { audioUrl: response.audioUrl }
: {}),
...(response.chatAction
? { chatAction: response.chatAction }
: response.commercialAction
? { commercialAction: response.commercialAction }
: {}),
...(response.paymentGuidance
? { paymentGuidance: response.paymentGuidance }
: response.chatAction
? { chatAction: response.chatAction }
: response.commercialAction
? { commercialAction: response.commercialAction }
: {}),
...deriveUiLockFields(response.lockDetail, response.image.url),
};
}
+3
View File
@@ -53,6 +53,7 @@ export function applyHttpSendOutput(
...finishPendingReply(context),
upgradePromptVisible: true,
upgradeReason,
paymentGuidance: response.paymentGuidance,
...sendCapabilityState,
};
}
@@ -60,6 +61,7 @@ export function applyHttpSendOutput(
if (!reply) {
return {
...finishPendingReply(context),
paymentGuidance: response.paymentGuidance,
...sendCapabilityState,
};
}
@@ -69,6 +71,7 @@ export function applyHttpSendOutput(
...finishPendingReply(context),
upgradePromptVisible: false,
upgradeReason: null,
paymentGuidance: response.paymentGuidance,
...sendCapabilityState,
};
}
+3
View File
@@ -1,6 +1,7 @@
import { fromPromise } from "xstate";
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
import type { PaymentGuidance } from "@/data/schemas/chat";
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result";
@@ -21,6 +22,7 @@ export interface UnlockHistoryOutput {
reason: string;
shortfallCredits: number;
messages: UiMessage[];
paymentGuidance?: PaymentGuidance | null;
}
export const unlockHistoryActor = fromPromise<
@@ -50,6 +52,7 @@ export const unlockHistoryActor = fromPromise<
reason: unlockResult.data.reason,
shortfallCredits: unlockResult.data.shortfallCredits,
messages: history.messages,
paymentGuidance: unlockResult.data.paymentGuidance,
};
});
+5 -2
View File
@@ -45,7 +45,8 @@ const appendGuestUserMessageAction = historyMachineSetup.assign(
],
outgoingMessageRevision: context.outgoingMessageRevision + 1,
upgradePromptVisible: false,
upgradeReason: null,
upgradeReason: null,
paymentGuidance: null,
};
},
);
@@ -73,7 +74,8 @@ const appendUserMessageAction = historyMachineSetup.assign(
],
outgoingMessageRevision: context.outgoingMessageRevision + 1,
upgradePromptVisible: false,
upgradeReason: null,
upgradeReason: null,
paymentGuidance: null,
};
},
);
@@ -124,6 +126,7 @@ const clearUpgradePromptAction = historyMachineSetup.assign(() => ({
canSendMessage: true,
requiredCredits: 0,
shortfallCredits: 0,
paymentGuidance: null,
}));
const appendGuestUserImageAction = historyMachineSetup.assign(
+11
View File
@@ -22,6 +22,7 @@ const showUnlockHistoryPromptAction = sendMachineSetup.assign(
unlockHistoryPromptVisible: true,
lockedHistoryCount: countLockedHistoryMessages(context.messages),
unlockHistoryError: null,
unlockHistoryPaymentGuidance: null,
}),
);
@@ -29,12 +30,14 @@ const dismissUnlockHistoryPromptAction = sendMachineSetup.assign(() => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
unlockHistoryError: null,
unlockHistoryPaymentGuidance: null,
}));
const markUnlockHistoryStartedAction = sendMachineSetup.assign(() => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
unlockHistoryError: null,
unlockHistoryPaymentGuidance: null,
}));
const markUnlockHistoryFailedAction = sendMachineSetup.assign(() => ({
@@ -76,6 +79,7 @@ const applyUnlockMessageSucceededAction = sendMachineSetup.assign(
creditsCharged: output.response.creditsCharged,
requiredCredits: 0,
shortfallCredits: 0,
paymentGuidance: null,
};
},
);
@@ -112,11 +116,15 @@ const requestUnlockPaymentFromOutputAction = sendMachineSetup.assign(
creditBalance: output.response.creditBalance,
requiredCredits: output.response.requiredCredits,
shortfallCredits: output.response.shortfallCredits,
...(output.response.paymentGuidance
? { paymentGuidance: output.response.paymentGuidance }
: {}),
}
: null,
creditBalance: output.response.creditBalance,
requiredCredits: output.response.requiredCredits,
shortfallCredits: output.response.shortfallCredits,
paymentGuidance: output.response.paymentGuidance,
};
},
);
@@ -191,6 +199,9 @@ const applyUnlockHistoryOutputAction =
unlockHistoryError: insufficientBalance
? `Insufficient credits. You still need ${event.output.shortfallCredits} credits.`
: null,
unlockHistoryPaymentGuidance: insufficientBalance
? event.output.paymentGuidance ?? null
: null,
};
});
+6 -1
View File
@@ -1,5 +1,9 @@
import { z } from "zod";
import { ChatActionSchema, CommercialActionSchema } from "@/data/schemas/chat";
import {
ChatActionSchema,
CommercialActionSchema,
PaymentGuidanceSchema,
} from "@/data/schemas/chat";
export const UiMessageSchema = z.object({
displayId: z.string().min(1),
@@ -19,6 +23,7 @@ export const UiMessageSchema = z.object({
privateMessageHint: z.string().nullable().optional(),
commercialAction: CommercialActionSchema.nullable().optional(),
chatAction: ChatActionSchema.nullable().optional(),
paymentGuidance: PaymentGuidanceSchema.nullable().optional(),
});
export type UiMessage = z.infer<typeof UiMessageSchema>;