feat(chat): unlock history after payment
This commit is contained in:
@@ -4,6 +4,7 @@ import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
|
||||
import type { ChatState } from "@/stores/chat/chat-state";
|
||||
import {
|
||||
applyHttpSendOutput,
|
||||
countLockedHistoryMessages,
|
||||
localMessagesToUi,
|
||||
sendResponseToUiMessage,
|
||||
} from "@/stores/chat/chat-machine.helpers";
|
||||
@@ -43,6 +44,11 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
||||
hasMore: true,
|
||||
historyOffset: 0,
|
||||
historyLoaded: true,
|
||||
paymentUnlockPending: false,
|
||||
unlockHistoryPromptVisible: false,
|
||||
lockedHistoryCount: 0,
|
||||
isUnlockingHistory: false,
|
||||
unlockHistoryError: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -231,3 +237,40 @@ describe("localMessagesToUi", () => {
|
||||
expect(message.imagePaywalled).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("countLockedHistoryMessages", () => {
|
||||
it("counts only unlockable locked AI messages", () => {
|
||||
expect(
|
||||
countLockedHistoryMessages([
|
||||
{
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
locked: true,
|
||||
lockReason: "private_message",
|
||||
},
|
||||
{
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
locked: true,
|
||||
lockReason: "voice_message",
|
||||
},
|
||||
{
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
locked: true,
|
||||
lockReason: "daily_limit",
|
||||
},
|
||||
{
|
||||
content: "user text",
|
||||
isFromAI: false,
|
||||
date: "2026-06-29",
|
||||
locked: true,
|
||||
lockReason: "private_message",
|
||||
},
|
||||
]),
|
||||
).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,15 @@ interface SendMessageHttpOutput {
|
||||
reply: UiMessage | null;
|
||||
}
|
||||
|
||||
interface UnlockHistoryOutput {
|
||||
unlocked: boolean;
|
||||
reason: string;
|
||||
shortfallCredits: number;
|
||||
messages: UiMessage[];
|
||||
hasMore: boolean;
|
||||
newOffset: number;
|
||||
}
|
||||
|
||||
function makeChatSendResponse(): ChatSendResponse {
|
||||
return ChatSendResponse.from({
|
||||
reply: "",
|
||||
@@ -50,6 +59,7 @@ function makeChatSendResponse(): ChatSendResponse {
|
||||
function createTestChatMachine(
|
||||
options: {
|
||||
historyMessages?: UiMessage[];
|
||||
unlockHistoryOutput?: UnlockHistoryOutput;
|
||||
} = {},
|
||||
) {
|
||||
return chatMachine.provide({
|
||||
@@ -90,6 +100,15 @@ function createTestChatMachine(
|
||||
});
|
||||
return () => undefined;
|
||||
}),
|
||||
unlockHistory: fromPromise<UnlockHistoryOutput>(async () => ({
|
||||
unlocked: true,
|
||||
reason: "ok",
|
||||
shortfallCredits: 0,
|
||||
messages: [],
|
||||
hasMore: false,
|
||||
newOffset: 0,
|
||||
...options.unlockHistoryOutput,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -213,6 +232,14 @@ describe("chatMachine transitions", () => {
|
||||
});
|
||||
return () => undefined;
|
||||
}),
|
||||
unlockHistory: fromPromise<UnlockHistoryOutput>(async () => ({
|
||||
unlocked: true,
|
||||
reason: "ok",
|
||||
shortfallCredits: 0,
|
||||
messages: [],
|
||||
hasMore: false,
|
||||
newOffset: 0,
|
||||
})),
|
||||
},
|
||||
});
|
||||
const actor = createActor(machine).start();
|
||||
@@ -235,4 +262,96 @@ describe("chatMachine transitions", () => {
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("prompts before unlocking multiple locked history messages after payment", async () => {
|
||||
const actor = createActor(
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
locked: true,
|
||||
lockReason: "private_message",
|
||||
},
|
||||
{
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
locked: true,
|
||||
lockReason: "voice_message",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
|
||||
actor.send({ type: "ChatPaymentSucceeded" });
|
||||
|
||||
expect(actor.getSnapshot().context.unlockHistoryPromptVisible).toBe(true);
|
||||
expect(actor.getSnapshot().context.lockedHistoryCount).toBe(2);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("unlocks history after the prompt is confirmed", async () => {
|
||||
const actor = createActor(
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
locked: true,
|
||||
lockReason: "private_message",
|
||||
},
|
||||
{
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
locked: true,
|
||||
lockReason: "voice_message",
|
||||
},
|
||||
],
|
||||
unlockHistoryOutput: {
|
||||
unlocked: true,
|
||||
reason: "ok",
|
||||
shortfallCredits: 0,
|
||||
messages: [
|
||||
{
|
||||
content: "unlocked",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
locked: false,
|
||||
lockReason: null,
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
newOffset: 1,
|
||||
},
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
|
||||
actor.send({ type: "ChatPaymentSucceeded" });
|
||||
actor.send({ type: "ChatUnlockHistoryConfirmed" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
|
||||
expect(actor.getSnapshot().context.unlockHistoryPromptVisible).toBe(false);
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{ content: "unlocked", locked: false },
|
||||
]);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,10 @@ interface ChatState {
|
||||
historyOffset: number;
|
||||
/** history 初始加载完成标志(local → network → save 跑完) —— UI 可用此显示 loading */
|
||||
historyLoaded: boolean;
|
||||
unlockHistoryPromptVisible: boolean;
|
||||
lockedHistoryCount: number;
|
||||
isUnlockingHistory: boolean;
|
||||
unlockHistoryError: string | null;
|
||||
}
|
||||
|
||||
const ChatStateCtx = createContext<ChatState | null>(null);
|
||||
@@ -55,6 +59,10 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
||||
hasMore: state.context.hasMore,
|
||||
historyOffset: state.context.historyOffset,
|
||||
historyLoaded: state.context.historyLoaded,
|
||||
unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible,
|
||||
lockedHistoryCount: state.context.lockedHistoryCount,
|
||||
isUnlockingHistory: state.context.isUnlockingHistory,
|
||||
unlockHistoryError: state.context.unlockHistoryError,
|
||||
}),
|
||||
[state],
|
||||
);
|
||||
|
||||
@@ -24,6 +24,9 @@ export type ChatEvent =
|
||||
| { type: "ChatSendMessage"; content: string }
|
||||
| { type: "ChatSendImage"; imageBase64: string }
|
||||
| { type: "ChatLoadMoreHistory" }
|
||||
| { type: "ChatPaymentSucceeded" }
|
||||
| { type: "ChatUnlockHistoryConfirmed" }
|
||||
| { type: "ChatUnlockHistoryDismissed" }
|
||||
| { type: "ChatQueuedSendStarted" }
|
||||
| {
|
||||
type: "ChatQueuedHttpDone";
|
||||
|
||||
@@ -73,6 +73,33 @@ export const loadMoreHistoryActor = fromPromise<
|
||||
};
|
||||
});
|
||||
|
||||
export const unlockHistoryActor = fromPromise<{
|
||||
unlocked: boolean;
|
||||
reason: string;
|
||||
shortfallCredits: number;
|
||||
messages: UiMessage[];
|
||||
hasMore: boolean;
|
||||
newOffset: number;
|
||||
}>(async () => {
|
||||
const unlockResult = await chatRepo.unlockHistory();
|
||||
if (Result.isErr(unlockResult)) {
|
||||
log.error("[chat-machine] unlockHistoryActor failed", {
|
||||
error: unlockResult.error,
|
||||
});
|
||||
throw unlockResult.error;
|
||||
}
|
||||
|
||||
const history = await readAndSyncHistory();
|
||||
return {
|
||||
unlocked: unlockResult.data.unlocked,
|
||||
reason: unlockResult.data.reason,
|
||||
shortfallCredits: unlockResult.data.shortfallCredits,
|
||||
messages: history.messages,
|
||||
hasMore: history.hasMore,
|
||||
newOffset: history.newOffset,
|
||||
};
|
||||
});
|
||||
|
||||
export const httpMessageQueueActor = fromCallback<ChatEvent>(
|
||||
({ sendBack, receive }) => {
|
||||
return createMessageQueueActor(sendBack, receive);
|
||||
|
||||
@@ -62,6 +62,19 @@ export function localMessagesToUi(
|
||||
}));
|
||||
}
|
||||
|
||||
export function countLockedHistoryMessages(messages: readonly UiMessage[]): number {
|
||||
return messages.filter(isUnlockableLockedMessage).length;
|
||||
}
|
||||
|
||||
function isUnlockableLockedMessage(message: UiMessage): boolean {
|
||||
if (!message.isFromAI || message.locked !== true) return false;
|
||||
if (message.imagePaywalled === true) return true;
|
||||
return (
|
||||
message.lockReason === "private_message" ||
|
||||
message.lockReason === "voice_message"
|
||||
);
|
||||
}
|
||||
|
||||
function messageDateFromCreatedAt(createdAt: string): string {
|
||||
const parsed = new Date(createdAt);
|
||||
if (Number.isNaN(parsed.getTime())) return todayString();
|
||||
@@ -213,6 +226,32 @@ export function applyHttpSendOutput(
|
||||
};
|
||||
}
|
||||
|
||||
export function applyHistoryLoadedOutput(
|
||||
output: {
|
||||
messages: UiMessage[];
|
||||
hasMore: boolean;
|
||||
newOffset: number;
|
||||
},
|
||||
): Pick<
|
||||
ChatState,
|
||||
"messages" | "hasMore" | "historyOffset" | "historyLoaded"
|
||||
> {
|
||||
return {
|
||||
messages: output.messages,
|
||||
hasMore: output.hasMore,
|
||||
historyOffset: output.newOffset,
|
||||
historyLoaded: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldAutoUnlockHistory(messages: readonly UiMessage[]): boolean {
|
||||
return countLockedHistoryMessages(messages) === 1;
|
||||
}
|
||||
|
||||
export function shouldPromptUnlockHistory(messages: readonly UiMessage[]): boolean {
|
||||
return countLockedHistoryMessages(messages) > 1;
|
||||
}
|
||||
|
||||
export function normalizeLockDetail(
|
||||
detail: Record<string, unknown> | null,
|
||||
): ChatState["upgradeDetail"] {
|
||||
|
||||
+142
-16
@@ -42,14 +42,19 @@ import { ChatState, initialState } from "./chat-state";
|
||||
import type { ChatEvent } from "./chat-events";
|
||||
import {
|
||||
applyHttpSendOutput,
|
||||
applyHistoryLoadedOutput,
|
||||
beginPendingReply,
|
||||
countLockedHistoryMessages,
|
||||
finishPendingReply,
|
||||
shouldAutoUnlockHistory,
|
||||
shouldPromptUnlockHistory,
|
||||
} from "./chat-machine.helpers";
|
||||
import {
|
||||
loadHistoryActor,
|
||||
sendMessageHttpActor,
|
||||
loadMoreHistoryActor,
|
||||
httpMessageQueueActor,
|
||||
unlockHistoryActor,
|
||||
} from "./chat-machine.actors";
|
||||
|
||||
const log = new Logger("StoresChatChatMachine");
|
||||
@@ -72,6 +77,7 @@ export const chatMachine = setup({
|
||||
sendMessageHttp: sendMessageHttpActor,
|
||||
loadMoreHistory: loadMoreHistoryActor,
|
||||
httpMessageQueue: httpMessageQueueActor,
|
||||
unlockHistory: unlockHistoryActor,
|
||||
},
|
||||
actions: {
|
||||
enqueueMessage: sendTo("messageQueue", ({ event }) => event),
|
||||
@@ -211,6 +217,37 @@ export const chatMachine = setup({
|
||||
if (event.type !== "ChatQueuedHttpDone") return {};
|
||||
return applyHttpSendOutput(context, event.output);
|
||||
}),
|
||||
|
||||
markPaymentUnlockPending: assign(() => ({
|
||||
paymentUnlockPending: true,
|
||||
unlockHistoryError: null,
|
||||
})),
|
||||
|
||||
showUnlockHistoryPrompt: assign(({ context }) => ({
|
||||
paymentUnlockPending: false,
|
||||
unlockHistoryPromptVisible: true,
|
||||
lockedHistoryCount: countLockedHistoryMessages(context.messages),
|
||||
unlockHistoryError: null,
|
||||
})),
|
||||
|
||||
dismissUnlockHistoryPrompt: assign(() => ({
|
||||
paymentUnlockPending: false,
|
||||
unlockHistoryPromptVisible: false,
|
||||
unlockHistoryError: null,
|
||||
})),
|
||||
|
||||
markUnlockHistoryStarted: assign(() => ({
|
||||
paymentUnlockPending: false,
|
||||
unlockHistoryPromptVisible: false,
|
||||
isUnlockingHistory: true,
|
||||
unlockHistoryError: null,
|
||||
})),
|
||||
|
||||
markUnlockHistoryFailed: assign(() => ({
|
||||
isUnlockingHistory: false,
|
||||
unlockHistoryPromptVisible: true,
|
||||
unlockHistoryError: "Failed to unlock messages. Please try again.",
|
||||
})),
|
||||
},
|
||||
}).createMachine({
|
||||
id: "chat",
|
||||
@@ -264,12 +301,9 @@ export const chatMachine = setup({
|
||||
id: "loadHistory",
|
||||
src: "loadHistory",
|
||||
onDone: {
|
||||
actions: assign(({ event }) => ({
|
||||
messages: event.output.messages,
|
||||
hasMore: event.output.hasMore,
|
||||
historyOffset: event.output.newOffset,
|
||||
historyLoaded: true,
|
||||
})),
|
||||
actions: assign(({ event }) =>
|
||||
applyHistoryLoadedOutput(event.output),
|
||||
),
|
||||
},
|
||||
onError: {
|
||||
// 失败也标 loaded,不卡 init(让 UI 还是能进 ready,屏幕可能空)
|
||||
@@ -337,22 +371,80 @@ export const chatMachine = setup({
|
||||
ChatQueuedSendError: {
|
||||
actions: "appendQueuedSendErrorMessage",
|
||||
},
|
||||
ChatPaymentSucceeded: [
|
||||
{
|
||||
guard: ({ context }) =>
|
||||
context.historyLoaded && shouldAutoUnlockHistory(context.messages),
|
||||
target: ".unlockingHistory",
|
||||
actions: "markUnlockHistoryStarted",
|
||||
},
|
||||
{
|
||||
guard: ({ context }) =>
|
||||
context.historyLoaded &&
|
||||
shouldPromptUnlockHistory(context.messages),
|
||||
actions: "showUnlockHistoryPrompt",
|
||||
},
|
||||
{
|
||||
guard: ({ context }) => !context.historyLoaded,
|
||||
actions: "markPaymentUnlockPending",
|
||||
},
|
||||
{
|
||||
guard: ({ context }) => context.historyLoaded,
|
||||
actions: "dismissUnlockHistoryPrompt",
|
||||
},
|
||||
],
|
||||
ChatUnlockHistoryConfirmed: {
|
||||
target: ".unlockingHistory",
|
||||
actions: "markUnlockHistoryStarted",
|
||||
},
|
||||
ChatUnlockHistoryDismissed: {
|
||||
actions: "dismissUnlockHistoryPrompt",
|
||||
},
|
||||
},
|
||||
initial: "initializing",
|
||||
states: {
|
||||
initializing: {
|
||||
invoke: {
|
||||
src: "loadHistory",
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: assign(({ event }) => ({
|
||||
messages: event.output.messages,
|
||||
isLoadingMore: false,
|
||||
hasMore: event.output.hasMore,
|
||||
historyOffset: event.output.newOffset,
|
||||
historyLoaded: true,
|
||||
})),
|
||||
},
|
||||
onDone: [
|
||||
{
|
||||
guard: ({ context, event }) =>
|
||||
context.paymentUnlockPending &&
|
||||
shouldAutoUnlockHistory(event.output.messages),
|
||||
target: "unlockingHistory",
|
||||
actions: [
|
||||
assign(({ event }) => ({
|
||||
...applyHistoryLoadedOutput(event.output),
|
||||
isLoadingMore: false,
|
||||
})),
|
||||
"markUnlockHistoryStarted",
|
||||
],
|
||||
},
|
||||
{
|
||||
guard: ({ context, event }) =>
|
||||
context.paymentUnlockPending &&
|
||||
shouldPromptUnlockHistory(event.output.messages),
|
||||
target: "ready",
|
||||
actions: assign(({ event }) => ({
|
||||
...applyHistoryLoadedOutput(event.output),
|
||||
isLoadingMore: false,
|
||||
paymentUnlockPending: false,
|
||||
unlockHistoryPromptVisible: true,
|
||||
lockedHistoryCount: countLockedHistoryMessages(
|
||||
event.output.messages,
|
||||
),
|
||||
unlockHistoryError: null,
|
||||
})),
|
||||
},
|
||||
{
|
||||
target: "ready",
|
||||
actions: assign(({ event }) => ({
|
||||
...applyHistoryLoadedOutput(event.output),
|
||||
isLoadingMore: false,
|
||||
paymentUnlockPending: false,
|
||||
})),
|
||||
},
|
||||
],
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: assign({
|
||||
@@ -413,6 +505,40 @@ export const chatMachine = setup({
|
||||
},
|
||||
},
|
||||
},
|
||||
unlockingHistory: {
|
||||
invoke: {
|
||||
id: "unlockHistory",
|
||||
src: "unlockHistory",
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: assign(({ event }) => {
|
||||
const lockedHistoryCount = countLockedHistoryMessages(
|
||||
event.output.messages,
|
||||
);
|
||||
const insufficientBalance =
|
||||
!event.output.unlocked &&
|
||||
event.output.reason === "insufficient_balance";
|
||||
return {
|
||||
messages: event.output.messages,
|
||||
hasMore: event.output.hasMore,
|
||||
historyOffset: event.output.newOffset,
|
||||
historyLoaded: true,
|
||||
paymentUnlockPending: false,
|
||||
unlockHistoryPromptVisible: insufficientBalance,
|
||||
lockedHistoryCount,
|
||||
isUnlockingHistory: false,
|
||||
unlockHistoryError: insufficientBalance
|
||||
? `Insufficient credits. You still need ${event.output.shortfallCredits} credits.`
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: "markUnlockHistoryFailed",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -23,6 +23,11 @@ export interface ChatState {
|
||||
* - 不被 `loadMoreHistoryActor` 设置(翻页是另一码事)
|
||||
*/
|
||||
historyLoaded: boolean;
|
||||
paymentUnlockPending: boolean;
|
||||
unlockHistoryPromptVisible: boolean;
|
||||
lockedHistoryCount: number;
|
||||
isUnlockingHistory: boolean;
|
||||
unlockHistoryError: string | null;
|
||||
}
|
||||
|
||||
export const initialState: ChatState = {
|
||||
@@ -37,4 +42,9 @@ export const initialState: ChatState = {
|
||||
hasMore: true,
|
||||
historyOffset: 0,
|
||||
historyLoaded: false,
|
||||
paymentUnlockPending: false,
|
||||
unlockHistoryPromptVisible: false,
|
||||
lockedHistoryCount: 0,
|
||||
isUnlockingHistory: false,
|
||||
unlockHistoryError: null,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user