feat(chat): migrate chat protocol to lock detail
This commit is contained in:
@@ -21,9 +21,10 @@ import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
|
||||
interface ChatState {
|
||||
messages: MachineContext["messages"];
|
||||
isReplyingAI: boolean;
|
||||
paywallTriggered: boolean;
|
||||
paywallReason: MachineContext["paywallReason"];
|
||||
paywallDetail: MachineContext["paywallDetail"];
|
||||
upgradePromptVisible: boolean;
|
||||
upgradeReason: MachineContext["upgradeReason"];
|
||||
upgradeHint: MachineContext["upgradeHint"];
|
||||
upgradeDetail: MachineContext["upgradeDetail"];
|
||||
unlockingPrivateMessageId: MachineContext["unlockingPrivateMessageId"];
|
||||
isLoadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
@@ -47,9 +48,10 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
||||
() => ({
|
||||
messages: state.context.messages,
|
||||
isReplyingAI: state.context.isReplyingAI,
|
||||
paywallTriggered: state.context.paywallTriggered,
|
||||
paywallReason: state.context.paywallReason,
|
||||
paywallDetail: state.context.paywallDetail,
|
||||
upgradePromptVisible: state.context.upgradePromptVisible,
|
||||
upgradeReason: state.context.upgradeReason,
|
||||
upgradeHint: state.context.upgradeHint,
|
||||
upgradeDetail: state.context.upgradeDetail,
|
||||
unlockingPrivateMessageId: state.context.unlockingPrivateMessageId,
|
||||
isLoadingMore: state.context.isLoadingMore,
|
||||
hasMore: state.context.hasMore,
|
||||
|
||||
@@ -27,13 +27,17 @@ export type ChatEvent =
|
||||
| { type: "ChatSendImage"; imageBase64: string }
|
||||
| { type: "ChatUnlockPrivateMessage"; messageId: string }
|
||||
| { type: "ChatLoadMoreHistory" }
|
||||
| { type: "ChatImageReceived"; imageUrl: string }
|
||||
| { type: "ChatImageReceived"; url: string }
|
||||
| {
|
||||
type: "ChatPaywallStatusReceived";
|
||||
paywallTriggered: boolean;
|
||||
showUpgrade: boolean;
|
||||
imageType: string | null;
|
||||
imageUrl: string | null;
|
||||
lockDetail: {
|
||||
locked: boolean;
|
||||
showContent: boolean;
|
||||
showUpgrade: boolean;
|
||||
reason: string | null;
|
||||
hint: string | null;
|
||||
detail: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
// WebSocket / AI 推句(由 chat 机器内部 actor 派回 —— "机器自驱动")
|
||||
| {
|
||||
|
||||
@@ -68,7 +68,7 @@ export const loadHistoryActor = fromPromise<{
|
||||
* HTTP 发送消息(一次发送 = 一次返回)
|
||||
* - 后端响应就是 AI 回复(`ChatSendResponse.reply: string`)
|
||||
* - 不再调 `getLocalMessages()`(多此一举)
|
||||
* - 返回完整 response + 可追加的 reply,方便处理后端 paywall blocked 响应
|
||||
* - 返回完整 response + 可追加的 reply,方便处理后端 lockDetail 响应
|
||||
*/
|
||||
export const sendMessageHttpActor = fromPromise<
|
||||
{ response: ChatSendResponse; reply: UiMessage | null },
|
||||
@@ -79,9 +79,13 @@ export const sendMessageHttpActor = fromPromise<
|
||||
log.error("[chat-machine] sendMessageHttpActor failed", { error: result.error });
|
||||
throw result.error;
|
||||
}
|
||||
const isDailyLimit =
|
||||
result.data.lockDetail.locked &&
|
||||
result.data.lockDetail.showUpgrade &&
|
||||
result.data.lockDetail.reason === "daily_limit";
|
||||
return {
|
||||
response: result.data,
|
||||
reply: result.data.blocked ? null : sendResponseToUiMessage(result.data),
|
||||
reply: isDailyLimit ? null : sendResponseToUiMessage(result.data),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -170,16 +174,13 @@ export const chatWebSocketActor = fromCallback<ChatEvent, { token: string }>(
|
||||
done: p.done,
|
||||
});
|
||||
};
|
||||
ws.onImage = (imageUrl) => {
|
||||
sendBack({ type: "ChatImageReceived", imageUrl });
|
||||
ws.onImage = (url) => {
|
||||
sendBack({ type: "ChatImageReceived", url });
|
||||
};
|
||||
ws.onPaywallStatus = (p) => {
|
||||
sendBack({
|
||||
type: "ChatPaywallStatusReceived",
|
||||
paywallTriggered: p.paywallTriggered,
|
||||
showUpgrade: p.showUpgrade,
|
||||
imageType: p.imageType,
|
||||
imageUrl: p.imageUrl,
|
||||
lockDetail: p,
|
||||
});
|
||||
};
|
||||
ws.onError = (msg) => {
|
||||
|
||||
@@ -31,13 +31,20 @@ export const chatRepo: IChatRepository = chatRepository;
|
||||
export function localMessagesToUi(
|
||||
records: ReadonlyArray<{
|
||||
id?: string;
|
||||
type?: string;
|
||||
content: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
imageUrl?: string | null;
|
||||
isPrivate?: boolean | null;
|
||||
privateLocked?: boolean | null;
|
||||
privateHint?: string | null;
|
||||
audioUrl?: string | null;
|
||||
image?: { type: string | null; url: string | null };
|
||||
lockDetail?: {
|
||||
locked: boolean;
|
||||
showContent: boolean;
|
||||
showUpgrade: boolean;
|
||||
reason: string | null;
|
||||
hint: string | null;
|
||||
detail: Record<string, unknown> | null;
|
||||
};
|
||||
}>,
|
||||
): UiMessage[] {
|
||||
return records.map((m) => ({
|
||||
@@ -45,14 +52,13 @@ export function localMessagesToUi(
|
||||
content: getAiMessageDisplayContent({
|
||||
content: m.content,
|
||||
isFromAI: m.role === "assistant",
|
||||
imageUrl: m.imageUrl,
|
||||
hasImage: Boolean(m.image?.url),
|
||||
lockDetail: m.lockDetail,
|
||||
}),
|
||||
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 } : {}),
|
||||
...(m.audioUrl ? { audioUrl: m.audioUrl } : {}),
|
||||
...deriveUiLockFields(m.lockDetail, m.image?.url),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -65,9 +71,14 @@ function messageDateFromCreatedAt(createdAt: string): string {
|
||||
function getAiMessageDisplayContent(input: {
|
||||
content: string;
|
||||
isFromAI: boolean;
|
||||
imageUrl?: string | null;
|
||||
hasImage?: boolean;
|
||||
lockDetail?: {
|
||||
showContent: boolean;
|
||||
reason: string | null;
|
||||
};
|
||||
}): string {
|
||||
return input.isFromAI && input.imageUrl ? "" : input.content;
|
||||
if (input.lockDetail?.showContent === false) return "";
|
||||
return input.isFromAI && input.hasImage ? "" : input.content;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,20 +92,38 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
|
||||
content: getAiMessageDisplayContent({
|
||||
content: response.reply,
|
||||
isFromAI: true,
|
||||
imageUrl: response.imageUrl,
|
||||
hasImage: Boolean(response.image.url),
|
||||
lockDetail: response.lockDetail,
|
||||
}),
|
||||
isFromAI: true,
|
||||
date: todayString(new Date(response.timestamp)),
|
||||
...(response.imageUrl ? { imageUrl: response.imageUrl } : {}),
|
||||
...(response.showUpgrade && response.imageUrl
|
||||
? { imagePaywalled: true }
|
||||
...(response.audioUrl ? { audioUrl: response.audioUrl } : {}),
|
||||
...deriveUiLockFields(response.lockDetail, response.image.url),
|
||||
};
|
||||
}
|
||||
|
||||
function deriveUiLockFields(
|
||||
lockDetail:
|
||||
| {
|
||||
locked: boolean;
|
||||
showContent: boolean;
|
||||
showUpgrade: boolean;
|
||||
reason: string | null;
|
||||
hint: string | null;
|
||||
}
|
||||
| undefined,
|
||||
imageUrl?: string | null,
|
||||
): Partial<UiMessage> {
|
||||
const isPrivateMessage = lockDetail?.reason === "private_message";
|
||||
return {
|
||||
...(imageUrl ? { imageUrl } : {}),
|
||||
...(imageUrl && lockDetail?.showUpgrade ? { imagePaywalled: true } : {}),
|
||||
...(isPrivateMessage ? { isPrivate: true } : {}),
|
||||
...(isPrivateMessage && lockDetail?.locked
|
||||
? { lockedPrivate: true }
|
||||
: {}),
|
||||
...(response.isPrivate != null ? { isPrivate: response.isPrivate } : {}),
|
||||
...(response.privateLocked != null
|
||||
? { privateLocked: response.privateLocked }
|
||||
: {}),
|
||||
...(response.privateHint != null
|
||||
? { privateHint: response.privateHint }
|
||||
...(isPrivateMessage && lockDetail?.hint
|
||||
? { privateMessageHint: lockDetail.hint }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -110,13 +139,13 @@ export function applyHttpSendOutput(
|
||||
): Partial<ChatState> {
|
||||
const { response, reply } = output;
|
||||
|
||||
const lockDetail = response.lockDetail;
|
||||
const isMessageLimitBlocked =
|
||||
response.blocked === true &&
|
||||
(response.blockReason === "daily_limit" ||
|
||||
response.blockReason === "total_limit");
|
||||
lockDetail.locked &&
|
||||
lockDetail.showUpgrade &&
|
||||
lockDetail.reason === "daily_limit";
|
||||
|
||||
if (isMessageLimitBlocked) {
|
||||
const detail = response.blockDetail;
|
||||
const lastMessage = context.messages[context.messages.length - 1];
|
||||
const messages =
|
||||
lastMessage && !lastMessage.isFromAI
|
||||
@@ -126,21 +155,10 @@ export function applyHttpSendOutput(
|
||||
return {
|
||||
messages,
|
||||
isReplyingAI: false,
|
||||
paywallTriggered: true,
|
||||
paywallReason:
|
||||
response.blockReason === "total_limit" ? "total_limit" : "daily_limit",
|
||||
paywallDetail: detail
|
||||
? {
|
||||
type: detail.type,
|
||||
...(detail.usedToday != null
|
||||
? { usedToday: detail.usedToday }
|
||||
: {}),
|
||||
...(detail.usedTotal != null
|
||||
? { usedTotal: detail.usedTotal }
|
||||
: {}),
|
||||
limit: detail.limit,
|
||||
}
|
||||
: null,
|
||||
upgradePromptVisible: true,
|
||||
upgradeReason: "daily_limit",
|
||||
upgradeHint: lockDetail.hint,
|
||||
upgradeDetail: normalizeLockDetail(lockDetail.detail),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -150,22 +168,40 @@ export function applyHttpSendOutput(
|
||||
};
|
||||
}
|
||||
|
||||
if (response.showUpgrade) {
|
||||
if (lockDetail.showUpgrade && lockDetail.reason === "private_message") {
|
||||
return {
|
||||
messages: [...context.messages, reply],
|
||||
isReplyingAI: false,
|
||||
paywallTriggered: false,
|
||||
paywallReason: null,
|
||||
paywallDetail: null,
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
upgradeHint: null,
|
||||
upgradeDetail: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
messages: [...context.messages, reply],
|
||||
isReplyingAI: false,
|
||||
paywallTriggered: false,
|
||||
paywallReason: null,
|
||||
paywallDetail: null,
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
upgradeHint: null,
|
||||
upgradeDetail: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeLockDetail(
|
||||
detail: Record<string, unknown> | null,
|
||||
): ChatState["upgradeDetail"] {
|
||||
if (!detail) return null;
|
||||
return {
|
||||
...(typeof detail.type === "string" ? { type: detail.type } : {}),
|
||||
...(typeof detail.usedToday === "number"
|
||||
? { usedToday: detail.usedToday }
|
||||
: {}),
|
||||
...(typeof detail.usedTotal === "number"
|
||||
? { usedTotal: detail.usedTotal }
|
||||
: {}),
|
||||
...(typeof detail.limit === "number" ? { limit: detail.limit } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,15 +27,15 @@
|
||||
* - 每个任务都是独立 actor,不互相等待(除非 `always` barrier)
|
||||
*
|
||||
* 消息发送路径(业务事实):
|
||||
* - guestSession:走 HTTP,消息数量限制以后端 `blocked` 响应为准
|
||||
* - nonVipUserSession:走 HTTP,让后端返回 daily_limit blocked
|
||||
* - guestSession:走 HTTP,消息数量限制以后端 `lockDetail` 响应为准
|
||||
* - nonVipUserSession:走 HTTP,让后端返回 daily_limit lockDetail
|
||||
* - vipUserSession + WS 已连:走 WS,不受每日免费次数限制
|
||||
* - `ChatWebSocketConnected` 事件 → `wsConnected = true`
|
||||
* - `vipUserSession.exit` action → `wsConnected = false`(WS actor cleanup 时也会跑 exit)
|
||||
*
|
||||
* 配额:
|
||||
* - 前端不再处理本地消息额度;游客 / 注册用户均以后端响应为准。
|
||||
* - 后端返回 `blocked=true && blockReason="daily_limit"` 时展示会员引导。
|
||||
* - 后端返回 `lockDetail.reason="daily_limit"` 且需要升级时展示会员引导。
|
||||
*
|
||||
*/
|
||||
|
||||
@@ -45,7 +45,10 @@ import { todayString, Logger } from "@/utils";
|
||||
|
||||
import { ChatState, initialState } from "./chat-state";
|
||||
import type { ChatEvent } from "./chat-events";
|
||||
import { applyHttpSendOutput } from "./chat-machine.helpers";
|
||||
import {
|
||||
applyHttpSendOutput,
|
||||
normalizeLockDetail,
|
||||
} from "./chat-machine.helpers";
|
||||
import {
|
||||
loadHistoryActor,
|
||||
sendMessageHttpActor,
|
||||
@@ -112,9 +115,10 @@ export const chatMachine = setup({
|
||||
},
|
||||
],
|
||||
isReplyingAI: true,
|
||||
paywallTriggered: false,
|
||||
paywallReason: null,
|
||||
paywallDetail: null,
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
upgradeHint: null,
|
||||
upgradeDetail: null,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -139,9 +143,10 @@ export const chatMachine = setup({
|
||||
},
|
||||
],
|
||||
isReplyingAI: true,
|
||||
paywallTriggered: false,
|
||||
paywallReason: null,
|
||||
paywallDetail: null,
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
upgradeHint: null,
|
||||
upgradeDetail: null,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -164,9 +169,10 @@ export const chatMachine = setup({
|
||||
},
|
||||
],
|
||||
isReplyingAI: true,
|
||||
paywallTriggered: false,
|
||||
paywallReason: null,
|
||||
paywallDetail: null,
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
upgradeHint: null,
|
||||
upgradeDetail: null,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -190,9 +196,10 @@ export const chatMachine = setup({
|
||||
},
|
||||
],
|
||||
isReplyingAI: true,
|
||||
paywallTriggered: false,
|
||||
paywallReason: null,
|
||||
paywallDetail: null,
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
upgradeHint: null,
|
||||
upgradeDetail: null,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -244,14 +251,14 @@ export const chatMachine = setup({
|
||||
messages[messages.length - 1] = {
|
||||
...last,
|
||||
content: "",
|
||||
imageUrl: event.imageUrl,
|
||||
imageUrl: event.url,
|
||||
};
|
||||
} else {
|
||||
messages.push({
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: todayString(),
|
||||
imageUrl: event.imageUrl,
|
||||
imageUrl: event.url,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -262,25 +269,31 @@ export const chatMachine = setup({
|
||||
|
||||
return {
|
||||
messages,
|
||||
paywallTriggered: false,
|
||||
paywallReason: null,
|
||||
paywallDetail: null,
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
upgradeHint: null,
|
||||
upgradeDetail: null,
|
||||
};
|
||||
}),
|
||||
|
||||
applyPaywallStatus: assign(({ event }) => {
|
||||
if (event.type !== "ChatPaywallStatusReceived") return {};
|
||||
if (!event.showUpgrade) {
|
||||
if (!event.lockDetail.showUpgrade) {
|
||||
return {
|
||||
paywallTriggered: false,
|
||||
paywallReason: null,
|
||||
paywallDetail: null,
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
upgradeHint: null,
|
||||
upgradeDetail: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
paywallTriggered: true,
|
||||
paywallReason: "photo_paywall",
|
||||
paywallDetail: null,
|
||||
upgradePromptVisible: true,
|
||||
upgradeReason:
|
||||
event.lockDetail.reason === "private_message"
|
||||
? "private_message"
|
||||
: "image",
|
||||
upgradeHint: event.lockDetail.hint,
|
||||
upgradeDetail: normalizeLockDetail(event.lockDetail.detail),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -288,9 +301,10 @@ export const chatMachine = setup({
|
||||
if (event.type !== "ChatUnlockPrivateMessage") return {};
|
||||
return {
|
||||
unlockingPrivateMessageId: event.messageId,
|
||||
paywallTriggered: false,
|
||||
paywallReason: null,
|
||||
paywallDetail: null,
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
upgradeHint: null,
|
||||
upgradeDetail: null,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -309,25 +323,27 @@ export const chatMachine = setup({
|
||||
? {
|
||||
...message,
|
||||
content: response.content ?? message.content,
|
||||
privateLocked: false,
|
||||
privateHint: null,
|
||||
lockedPrivate: false,
|
||||
privateMessageHint: null,
|
||||
isPrivate: message.isPrivate ?? true,
|
||||
}
|
||||
: message,
|
||||
),
|
||||
unlockingPrivateMessageId: null,
|
||||
paywallTriggered: false,
|
||||
paywallReason: null,
|
||||
paywallDetail: null,
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
upgradeHint: null,
|
||||
upgradeDetail: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (response.showUpgrade) {
|
||||
return {
|
||||
unlockingPrivateMessageId: null,
|
||||
paywallTriggered: true,
|
||||
paywallReason: "private_paywall",
|
||||
paywallDetail: {
|
||||
upgradePromptVisible: true,
|
||||
upgradeReason: "private_message",
|
||||
upgradeHint: null,
|
||||
upgradeDetail: {
|
||||
usedToday: response.privateUsedToday,
|
||||
limit: response.privateFreeLimit,
|
||||
},
|
||||
|
||||
@@ -18,19 +18,15 @@ export interface ChatState {
|
||||
* - 非 VIP / 游客:固定 false,走 HTTP
|
||||
*/
|
||||
wsConnected: boolean;
|
||||
paywallTriggered: boolean;
|
||||
paywallReason:
|
||||
| "daily_limit"
|
||||
| "total_limit"
|
||||
| "photo_paywall"
|
||||
| "private_paywall"
|
||||
| null;
|
||||
paywallDetail:
|
||||
upgradePromptVisible: boolean;
|
||||
upgradeReason: "daily_limit" | "private_message" | "image" | null;
|
||||
upgradeHint: string | null;
|
||||
upgradeDetail:
|
||||
| {
|
||||
type?: string;
|
||||
usedToday?: number;
|
||||
usedTotal?: number;
|
||||
limit: number;
|
||||
limit?: number;
|
||||
}
|
||||
| null;
|
||||
unlockingPrivateMessageId: string | null;
|
||||
@@ -48,9 +44,10 @@ export const initialState: ChatState = {
|
||||
messages: [],
|
||||
isReplyingAI: false,
|
||||
wsConnected: false,
|
||||
paywallTriggered: false,
|
||||
paywallReason: null,
|
||||
paywallDetail: null,
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
upgradeHint: null,
|
||||
upgradeDetail: null,
|
||||
unlockingPrivateMessageId: null,
|
||||
isLoadingMore: false,
|
||||
hasMore: true,
|
||||
|
||||
Reference in New Issue
Block a user