feat(chat): add private message unlock flow
This commit is contained in:
@@ -24,6 +24,7 @@ interface ChatState {
|
||||
paywallTriggered: boolean;
|
||||
paywallReason: MachineContext["paywallReason"];
|
||||
paywallDetail: MachineContext["paywallDetail"];
|
||||
unlockingPrivateMessageId: MachineContext["unlockingPrivateMessageId"];
|
||||
isLoadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
historyOffset: number;
|
||||
@@ -49,6 +50,7 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
||||
paywallTriggered: state.context.paywallTriggered,
|
||||
paywallReason: state.context.paywallReason,
|
||||
paywallDetail: state.context.paywallDetail,
|
||||
unlockingPrivateMessageId: state.context.unlockingPrivateMessageId,
|
||||
isLoadingMore: state.context.isLoadingMore,
|
||||
hasMore: state.context.hasMore,
|
||||
historyOffset: state.context.historyOffset,
|
||||
|
||||
@@ -25,6 +25,7 @@ export type ChatEvent =
|
||||
// 业务事件
|
||||
| { type: "ChatSendMessage"; content: string }
|
||||
| { type: "ChatSendImage"; imageBase64: string }
|
||||
| { type: "ChatUnlockPrivateMessage"; messageId: string }
|
||||
| { type: "ChatLoadMoreHistory" }
|
||||
| { type: "ChatImageReceived"; imageUrl: string }
|
||||
| {
|
||||
|
||||
@@ -105,6 +105,41 @@ export const loadMoreHistoryActor = fromPromise<
|
||||
};
|
||||
});
|
||||
|
||||
export const unlockPrivateMessageActor = fromPromise<
|
||||
{
|
||||
messageId: string;
|
||||
response: import("@/data/dto/chat").UnlockPrivateResponse;
|
||||
},
|
||||
{ messageId: string }
|
||||
>(async ({ input }) => {
|
||||
const result = await chatRepo.unlockPrivateMessage(input.messageId);
|
||||
if (Result.isErr(result)) {
|
||||
log.error("[chat-machine] unlockPrivateMessageActor failed", {
|
||||
messageId: input.messageId,
|
||||
error: result.error,
|
||||
});
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (result.data.unlocked && result.data.content != null) {
|
||||
const localResult = await chatRepo.markPrivateMessageUnlockedInLocal(
|
||||
input.messageId,
|
||||
result.data.content,
|
||||
);
|
||||
if (Result.isErr(localResult)) {
|
||||
log.error("[chat-machine] unlockPrivateMessageActor local sync failed", {
|
||||
messageId: input.messageId,
|
||||
error: localResult.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: input.messageId,
|
||||
response: result.data,
|
||||
};
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// WebSocket: long-lived callback
|
||||
// ============================================================
|
||||
|
||||
@@ -49,17 +49,25 @@ export const chatRepo: IChatRepository = chatRepository;
|
||||
*/
|
||||
export function localMessagesToUi(
|
||||
records: ReadonlyArray<{
|
||||
id?: string;
|
||||
content: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
imageUrl?: string | null;
|
||||
isPrivate?: boolean | null;
|
||||
privateLocked?: boolean | null;
|
||||
privateHint?: string | null;
|
||||
}>,
|
||||
): UiMessage[] {
|
||||
return records.map((m) => ({
|
||||
...(m.id ? { id: m.id } : {}),
|
||||
content: m.content,
|
||||
isFromAI: m.role === "assistant",
|
||||
date: messageDateFromCreatedAt(m.createdAt),
|
||||
...(m.imageUrl ? { imageUrl: m.imageUrl } : {}),
|
||||
...(m.isPrivate != null ? { isPrivate: m.isPrivate } : {}),
|
||||
...(m.privateLocked != null ? { privateLocked: m.privateLocked } : {}),
|
||||
...(m.privateHint != null ? { privateHint: m.privateHint } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -76,6 +84,7 @@ function messageDateFromCreatedAt(createdAt: string): string {
|
||||
*/
|
||||
export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
|
||||
return {
|
||||
...(response.messageId ? { id: response.messageId } : {}),
|
||||
content: response.reply,
|
||||
isFromAI: true,
|
||||
date: todayString(new Date(response.timestamp)),
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
sendMessageWsActor,
|
||||
loadMoreHistoryActor,
|
||||
chatWebSocketActor,
|
||||
unlockPrivateMessageActor,
|
||||
} from "./chat-machine.actors";
|
||||
|
||||
const log = new Logger("StoresChatChatMachine");
|
||||
@@ -70,6 +71,7 @@ export const chatMachine = setup({
|
||||
sendMessageWs: sendMessageWsActor,
|
||||
loadMoreHistory: loadMoreHistoryActor,
|
||||
chatWebSocket: chatWebSocketActor,
|
||||
unlockPrivateMessage: unlockPrivateMessageActor,
|
||||
},
|
||||
actions: {
|
||||
startGuestSession: assign(() => ({
|
||||
@@ -276,6 +278,65 @@ export const chatMachine = setup({
|
||||
};
|
||||
}),
|
||||
|
||||
setUnlockingPrivateMessage: assign(({ event }) => {
|
||||
if (event.type !== "ChatUnlockPrivateMessage") return {};
|
||||
return {
|
||||
unlockingPrivateMessageId: event.messageId,
|
||||
paywallTriggered: false,
|
||||
paywallReason: null,
|
||||
paywallDetail: null,
|
||||
};
|
||||
}),
|
||||
|
||||
applyUnlockPrivateOutput: assign(({ context, event }) => {
|
||||
if (!("output" in event)) return {};
|
||||
const output = event.output as {
|
||||
messageId: string;
|
||||
response: import("@/data/dto/chat").UnlockPrivateResponse;
|
||||
};
|
||||
const { messageId, response } = output;
|
||||
|
||||
if (response.unlocked && response.content != null) {
|
||||
return {
|
||||
messages: context.messages.map((message) =>
|
||||
message.id === messageId
|
||||
? {
|
||||
...message,
|
||||
content: response.content ?? message.content,
|
||||
privateLocked: false,
|
||||
privateHint: null,
|
||||
isPrivate: message.isPrivate ?? true,
|
||||
}
|
||||
: message,
|
||||
),
|
||||
unlockingPrivateMessageId: null,
|
||||
paywallTriggered: false,
|
||||
paywallReason: null,
|
||||
paywallDetail: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (response.showUpgrade) {
|
||||
return {
|
||||
unlockingPrivateMessageId: null,
|
||||
paywallTriggered: true,
|
||||
paywallReason: "private_paywall",
|
||||
paywallDetail: {
|
||||
usedToday: response.privateUsedToday,
|
||||
limit: response.privateFreeLimit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
unlockingPrivateMessageId: null,
|
||||
};
|
||||
}),
|
||||
|
||||
clearUnlockingPrivateMessage: assign({
|
||||
unlockingPrivateMessageId: null,
|
||||
}),
|
||||
|
||||
setWsConnected: assign({ wsConnected: true }),
|
||||
clearWsConnected: assign({ wsConnected: false }),
|
||||
},
|
||||
@@ -356,6 +417,10 @@ export const chatMachine = setup({
|
||||
actions: "appendGuestUserImage",
|
||||
target: "sending",
|
||||
},
|
||||
ChatUnlockPrivateMessage: {
|
||||
actions: "setUnlockingPrivateMessage",
|
||||
target: "unlockingPrivate",
|
||||
},
|
||||
// 删除 ChatLoadMoreHistory / WS handlers —— 游客无服务端 history,也不连接 WS
|
||||
},
|
||||
},
|
||||
@@ -377,6 +442,23 @@ export const chatMachine = setup({
|
||||
},
|
||||
},
|
||||
},
|
||||
unlockingPrivate: {
|
||||
invoke: {
|
||||
src: "unlockPrivateMessage",
|
||||
input: ({ event }) => ({
|
||||
messageId:
|
||||
event.type === "ChatUnlockPrivateMessage" ? event.messageId : "",
|
||||
}),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: "applyUnlockPrivateOutput",
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: "clearUnlockingPrivateMessage",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -437,6 +519,10 @@ export const chatMachine = setup({
|
||||
ChatLoadMoreHistory: {
|
||||
target: "loadingMore",
|
||||
},
|
||||
ChatUnlockPrivateMessage: {
|
||||
actions: "setUnlockingPrivateMessage",
|
||||
target: "unlockingPrivate",
|
||||
},
|
||||
},
|
||||
},
|
||||
sendingViaHttp: {
|
||||
@@ -476,6 +562,23 @@ export const chatMachine = setup({
|
||||
},
|
||||
},
|
||||
},
|
||||
unlockingPrivate: {
|
||||
invoke: {
|
||||
src: "unlockPrivateMessage",
|
||||
input: ({ event }) => ({
|
||||
messageId:
|
||||
event.type === "ChatUnlockPrivateMessage" ? event.messageId : "",
|
||||
}),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: "applyUnlockPrivateOutput",
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: "clearUnlockingPrivateMessage",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -577,6 +680,10 @@ export const chatMachine = setup({
|
||||
ChatLoadMoreHistory: {
|
||||
target: "loadingMore",
|
||||
},
|
||||
ChatUnlockPrivateMessage: {
|
||||
actions: "setUnlockingPrivateMessage",
|
||||
target: "unlockingPrivate",
|
||||
},
|
||||
// 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected
|
||||
// 已上提到 vipUserSession.on,子级不再声明
|
||||
},
|
||||
@@ -651,6 +758,23 @@ export const chatMachine = setup({
|
||||
},
|
||||
},
|
||||
},
|
||||
unlockingPrivate: {
|
||||
invoke: {
|
||||
src: "unlockPrivateMessage",
|
||||
input: ({ event }) => ({
|
||||
messageId:
|
||||
event.type === "ChatUnlockPrivateMessage" ? event.messageId : "",
|
||||
}),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: "applyUnlockPrivateOutput",
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: "clearUnlockingPrivateMessage",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -19,8 +19,9 @@ export interface ChatState {
|
||||
*/
|
||||
wsConnected: boolean;
|
||||
paywallTriggered: boolean;
|
||||
paywallReason: "daily_limit" | "photo_paywall" | null;
|
||||
paywallReason: "daily_limit" | "photo_paywall" | "private_paywall" | null;
|
||||
paywallDetail: { usedToday: number; limit: number } | null;
|
||||
unlockingPrivateMessageId: string | null;
|
||||
isLoadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
historyOffset: number;
|
||||
@@ -38,6 +39,7 @@ export const initialState: ChatState = {
|
||||
paywallTriggered: false,
|
||||
paywallReason: null,
|
||||
paywallDetail: null,
|
||||
unlockingPrivateMessageId: null,
|
||||
isLoadingMore: false,
|
||||
hasMore: true,
|
||||
historyOffset: 0,
|
||||
|
||||
Reference in New Issue
Block a user