feat(chat): migrate chat protocol to lock detail

This commit is contained in:
2026-06-24 14:06:30 +08:00
parent c280a3c1ea
commit 42c03f8901
34 changed files with 609 additions and 373 deletions
+123
View File
@@ -0,0 +1,123 @@
图片 URL 长相
真实图片 URL 大概是这种公开 Supabase Storage 地址:
https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/42/a1b2c3d4.jpg
前端不用拼 URL,也不用鉴权,后端会直接返回完整 URL。前端只读:
data.image?.url
消息接口
POST https://proapi.banlv-ai.com/api/chat/send
响应 data 固定字段:
{
"reply": "这是我昨天傍晚的照片,那会儿在外面走了走。",
"audioUrl": "",
"messageId": "msg_123",
"timestamp": 1782271808947,
"isGuest": false,
"image": {
"type": "elio_schedule",
"url": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/42/a1b2c3d4.jpg"
},
"lockDetail": {
"locked": false,
"showContent": true,
"showUpgrade": false,
"reason": null,
"hint": null,
"detail": null
}
}
前端用法:
const d = res.data
if (d.lockDetail?.showContent !== false && d.reply) {
renderText(d.reply)
}
if (d.image?.url) {
renderImage(d.image.url)
}
if (d.audioUrl) {
renderAudio(d.audioUrl)
}
if (d.lockDetail?.showUpgrade) {
showVipPaywall(d.lockDetail.hint)
}
image.type 目前主要是 elio_schedule,无图时:
"image": { "type": null, "url": null }
被限额/需要充值时:
{
"reply": "",
"image": { "type": null, "url": null },
"lockDetail": {
"locked": true,
"showContent": false,
"showUpgrade": true,
"reason": "daily_limit",
"hint": "免费消息额度已用完,开通会员后可以继续聊天。",
"detail": {
"type": "daily_msg_limit",
"usedToday": 3,
"limit": 3
}
}
}
历史接口
GET https://proapi.banlv-ai.com/api/chat/history?limit=50&offset=0
响应结构:
{
"messages": [
{
"role": "user",
"type": "text",
"content": "你最近在干嘛",
"id": "msg_1",
"created_at": "2026-06-24T10:00:00Z",
"audioUrl": null,
"image": { "type": null, "url": null },
"lockDetail": {
"locked": false,
"showContent": true,
"showUpgrade": false,
"reason": null,
"hint": null,
"detail": null
}
},
{
"role": "assistant",
"type": "text",
"content": "",
"id": "msg_2",
"created_at": "2026-06-24T10:00:03Z",
"audioUrl": null,
"image": { "type": null, "url": null },
"lockDetail": {
"locked": true,
"showContent": false,
"showUpgrade": true,
"reason": "private_message",
"hint": "他好像想说什么悄悄话,解锁会员才能看到…",
"detail": { "messageId": "msg_2" }
}
}
],
"total": 200,
"limit": 50,
"offset": 0,
"isVip": false
}
历史前端用法:
for (const m of data.messages) {
if (m.lockDetail?.showContent === false) {
renderLockedCard(m.lockDetail.hint)
if (m.lockDetail?.showUpgrade) showVipEntry()
continue
}
renderMessage(m.role, m.content)
if (m.image?.url) renderImage(m.image.url)
if (m.audioUrl) renderAudio(m.audioUrl)
}
前端不要再读这些旧字段:mode、relationshipStage、currentMood、intimacyChange、newIntimacy、voiceUrl、imageUrl、imageType、privateLocked、privateHint。统一是 audioUrl、image.url、lockDetail。
+4 -8
View File
@@ -45,19 +45,15 @@ export function ChatScreen() {
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段) // isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
const isGuest = deriveIsGuest(authState.loginStatus); const isGuest = deriveIsGuest(authState.loginStatus);
// 消息数量限制由后端统一返回 blocked,前端不再处理本地额度。 // 消息数量限制由后端统一返回 lockDetail,前端不再处理本地额度。
const showMessageLimitBanner = const showMessageLimitBanner =
state.paywallTriggered && state.upgradePromptVisible && state.upgradeReason === "daily_limit";
(state.paywallReason === "daily_limit" ||
state.paywallReason === "total_limit");
const messageLimitTitle = const messageLimitTitle =
state.paywallReason === "total_limit" state.upgradeHint ?? "The limit for free chat times\nhas been reached";
? "The limit for free chat times\nhas been reached"
: undefined;
const messageLimitCta = isGuest ? "Log in to continue chatting" : undefined; const messageLimitCta = isGuest ? "Log in to continue chatting" : undefined;
const showPrivatePaywallBanner = const showPrivatePaywallBanner =
state.paywallTriggered && state.paywallReason === "private_paywall"; state.upgradePromptVisible && state.upgradeReason === "private_message";
const inlineUpgradeTitle = "Unlock VIP to view private messages"; const inlineUpgradeTitle = "Unlock VIP to view private messages";
const inlineUpgradeCta = "Activate VIP to view private messages"; const inlineUpgradeCta = "Activate VIP to view private messages";
+2 -2
View File
@@ -95,8 +95,8 @@ function renderMessagesWithDateHeaders(
imageUrl={item.message.imageUrl} imageUrl={item.message.imageUrl}
imagePaywalled={item.message.imagePaywalled} imagePaywalled={item.message.imagePaywalled}
isFromAI={item.message.isFromAI} isFromAI={item.message.isFromAI}
privateLocked={item.message.privateLocked} lockedPrivate={item.message.lockedPrivate}
privateHint={item.message.privateHint} privateMessageHint={item.message.privateMessageHint}
isUnlockingPrivate={ isUnlockingPrivate={
item.message.id != null && item.message.id != null &&
item.message.id === unlockingPrivateMessageId item.message.id === unlockingPrivateMessageId
@@ -3,7 +3,7 @@
* ChatQuotaExhaustedBanner 游客配额耗尽横幅 * ChatQuotaExhaustedBanner 游客配额耗尽横幅
* *
* 何时显示: * 何时显示:
* - 后端返回 blocked=true && blockReason="daily_limit" * - 后端返回 lockDetail.reason="daily_limit" 且 showUpgrade=true
* *
* 视觉规格(与设计稿对齐): * 视觉规格(与设计稿对齐):
* - 粉→品红渐变背景(与 splash 渐变一致) * - 粉→品红渐变背景(与 splash 渐变一致)
+8 -8
View File
@@ -20,8 +20,8 @@ export interface MessageBubbleProps {
imageUrl?: string | null; imageUrl?: string | null;
imagePaywalled?: boolean; imagePaywalled?: boolean;
isFromAI: boolean; isFromAI: boolean;
privateLocked?: boolean | null; lockedPrivate?: boolean | null;
privateHint?: string | null; privateMessageHint?: string | null;
isUnlockingPrivate?: boolean; isUnlockingPrivate?: boolean;
onUnlockPrivateMessage?: (messageId: string) => void; onUnlockPrivateMessage?: (messageId: string) => void;
onUnlockImagePaywall?: () => void; onUnlockImagePaywall?: () => void;
@@ -33,8 +33,8 @@ export function MessageBubble({
imageUrl, imageUrl,
imagePaywalled, imagePaywalled,
isFromAI, isFromAI,
privateLocked, lockedPrivate,
privateHint, privateMessageHint,
isUnlockingPrivate, isUnlockingPrivate,
onUnlockPrivateMessage, onUnlockPrivateMessage,
onUnlockImagePaywall, onUnlockImagePaywall,
@@ -52,8 +52,8 @@ export function MessageBubble({
imageUrl={imageUrl} imageUrl={imageUrl}
imagePaywalled={imagePaywalled} imagePaywalled={imagePaywalled}
isFromAI={true} isFromAI={true}
privateLocked={privateLocked} lockedPrivate={lockedPrivate}
privateHint={privateHint} privateMessageHint={privateMessageHint}
isUnlockingPrivate={isUnlockingPrivate} isUnlockingPrivate={isUnlockingPrivate}
onUnlockPrivateMessage={onUnlockPrivateMessage} onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockImagePaywall={onUnlockImagePaywall} onUnlockImagePaywall={onUnlockImagePaywall}
@@ -72,8 +72,8 @@ export function MessageBubble({
imageUrl={imageUrl} imageUrl={imageUrl}
imagePaywalled={imagePaywalled} imagePaywalled={imagePaywalled}
isFromAI={false} isFromAI={false}
privateLocked={privateLocked} lockedPrivate={lockedPrivate}
privateHint={privateHint} privateMessageHint={privateMessageHint}
isUnlockingPrivate={isUnlockingPrivate} isUnlockingPrivate={isUnlockingPrivate}
onUnlockPrivateMessage={onUnlockPrivateMessage} onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockImagePaywall={onUnlockImagePaywall} onUnlockImagePaywall={onUnlockImagePaywall}
+6 -6
View File
@@ -17,8 +17,8 @@ export interface MessageContentProps {
imageUrl?: string | null; imageUrl?: string | null;
imagePaywalled?: boolean; imagePaywalled?: boolean;
isFromAI: boolean; isFromAI: boolean;
privateLocked?: boolean | null; lockedPrivate?: boolean | null;
privateHint?: string | null; privateMessageHint?: string | null;
isUnlockingPrivate?: boolean; isUnlockingPrivate?: boolean;
onUnlockPrivateMessage?: (messageId: string) => void; onUnlockPrivateMessage?: (messageId: string) => void;
onUnlockImagePaywall?: () => void; onUnlockImagePaywall?: () => void;
@@ -32,15 +32,15 @@ export function MessageContent({
imageUrl, imageUrl,
imagePaywalled, imagePaywalled,
isFromAI, isFromAI,
privateLocked, lockedPrivate,
privateHint, privateMessageHint,
isUnlockingPrivate = false, isUnlockingPrivate = false,
onUnlockPrivateMessage, onUnlockPrivateMessage,
onUnlockImagePaywall, onUnlockImagePaywall,
}: MessageContentProps) { }: MessageContentProps) {
const hasImage = imageUrl != null && imageUrl.length > 0; const hasImage = imageUrl != null && imageUrl.length > 0;
const hasText = content.length > 0 && content !== IMAGE_PLACEHOLDER; const hasText = content.length > 0 && content !== IMAGE_PLACEHOLDER;
const isLockedPrivateMessage = privateLocked === true; const isLockedPrivateMessage = lockedPrivate === true;
return ( return (
<div <div
@@ -57,7 +57,7 @@ export function MessageContent({
> >
{isLockedPrivateMessage ? ( {isLockedPrivateMessage ? (
<PrivateMessageCard <PrivateMessageCard
hint={privateHint} hint={privateMessageHint}
isUnlocking={isUnlockingPrivate} isUnlocking={isUnlockingPrivate}
onUnlock={ onUnlock={
messageId messageId
@@ -14,7 +14,7 @@
*/ */
import { useEffect } from "react"; import { useEffect } from "react";
import { BrowserDetector, AppEnvUtil, SpAsyncUtil, pwaUtil } from "@/utils"; import { BrowserDetector, SpAsyncUtil, pwaUtil } from "@/utils";
import { PwaInstallDialog } from "./pwa-install-dialog"; import { PwaInstallDialog } from "./pwa-install-dialog";
import styles from "./pwa-install-overlay.module.css"; import styles from "./pwa-install-overlay.module.css";
+22 -39
View File
@@ -6,8 +6,6 @@
* - onTyping(isTyping) * - onTyping(isTyping)
* - onSentence(index, text, total, done) ← AI 逐句推送 * - onSentence(index, text, total, done) ← AI 逐句推送
* - onVoiceReady(index, audioUrl) * - onVoiceReady(index, audioUrl)
* - onIntimacyUpdate(intimacyChange, newIntimacy, relationshipStage)
* - onMoodUpdate(mood)
* - onError(errorMessage) * - onError(errorMessage)
*/ */
import { getApiConfig } from "@/core/net/config/api_config"; import { getApiConfig } from "@/core/net/config/api_config";
@@ -23,17 +21,13 @@ export interface SentencePayload {
done: boolean; done: boolean;
} }
export interface IntimacyPayload {
intimacyChange?: number;
newIntimacy?: number;
relationshipStage?: string;
}
export interface PaywallStatusPayload { export interface PaywallStatusPayload {
paywallTriggered: boolean; locked: boolean;
showContent: boolean;
showUpgrade: boolean; showUpgrade: boolean;
imageType: string | null; reason: string | null;
imageUrl: string | null; hint: string | null;
detail: Record<string, unknown> | null;
} }
export class ChatWebSocket { export class ChatWebSocket {
@@ -46,10 +40,8 @@ export class ChatWebSocket {
onTyping: ((isTyping: boolean) => void) | null = null; onTyping: ((isTyping: boolean) => void) | null = null;
onSentence: ((payload: SentencePayload) => void) | null = null; onSentence: ((payload: SentencePayload) => void) | null = null;
onVoiceReady: ((index: number, audioUrl: string) => void) | null = null; onVoiceReady: ((index: number, audioUrl: string) => void) | null = null;
onImage: ((imageUrl: string) => void) | null = null; onImage: ((url: string) => void) | null = null;
onPaywallStatus: ((payload: PaywallStatusPayload) => void) | null = null; onPaywallStatus: ((payload: PaywallStatusPayload) => void) | null = null;
onIntimacyUpdate: ((payload: IntimacyPayload) => void) | null = null;
onMoodUpdate: ((mood: string) => void) | null = null;
onError: ((errorMessage: string) => void) | null = null; onError: ((errorMessage: string) => void) | null = null;
constructor( constructor(
@@ -141,16 +133,13 @@ export class ChatWebSocket {
total?: number; total?: number;
done?: boolean; done?: boolean;
audioUrl?: string; audioUrl?: string;
intimacyChange?: number;
newIntimacy?: number;
relationshipStage?: string;
mood?: string;
error?: string; error?: string;
data?: { data?: {
imageUrl?: string | null; image?: {
paywallTriggered?: boolean; type?: string | null;
showUpgrade?: boolean; url?: string | null;
imageType?: string | null; };
lockDetail?: Partial<PaywallStatusPayload>;
}; };
}; };
try { try {
@@ -178,28 +167,22 @@ export class ChatWebSocket {
this.onVoiceReady?.(payload.index ?? 0, payload.audioUrl ?? ""); this.onVoiceReady?.(payload.index ?? 0, payload.audioUrl ?? "");
break; break;
case "image": { case "image": {
const imageUrl = payload.data?.imageUrl; const url = payload.data?.image?.url;
if (imageUrl) this.onImage?.(imageUrl); if (url) this.onImage?.(url);
break; break;
} }
case "paywall_status": case "paywall_status": {
const lockDetail = payload.data?.lockDetail;
this.onPaywallStatus?.({ this.onPaywallStatus?.({
paywallTriggered: payload.data?.paywallTriggered ?? false, locked: lockDetail?.locked ?? false,
showUpgrade: payload.data?.showUpgrade ?? false, showContent: lockDetail?.showContent ?? true,
imageType: payload.data?.imageType ?? null, showUpgrade: lockDetail?.showUpgrade ?? false,
imageUrl: payload.data?.imageUrl ?? null, reason: lockDetail?.reason ?? null,
hint: lockDetail?.hint ?? null,
detail: lockDetail?.detail ?? null,
}); });
break; break;
case "intimacy_update": }
this.onIntimacyUpdate?.({
intimacyChange: payload.intimacyChange,
newIntimacy: payload.newIntimacy,
relationshipStage: payload.relationshipStage,
});
break;
case "mood_update":
if (payload.mood) this.onMoodUpdate?.(payload.mood);
break;
case "error": case "error":
this.onError?.(payload.error ?? "Unknown error"); this.onError?.(payload.error ?? "Unknown error");
break; break;
+7 -5
View File
@@ -5,17 +5,19 @@ import {
ChatMessageSchema, ChatMessageSchema,
type ChatMessageInput, type ChatMessageInput,
type ChatMessageData, type ChatMessageData,
} from "@/data/schemas/chat/chat_message"; type ChatImageData,
type ChatLockDetailData,
} from "@/data/schemas/chat";
export class ChatMessage { export class ChatMessage {
declare readonly role: string; declare readonly role: string;
declare readonly type: string;
declare readonly content: string; declare readonly content: string;
declare readonly id: string; declare readonly id: string;
declare readonly createdAt: string; declare readonly createdAt: string;
declare readonly imageUrl: string | null; declare readonly audioUrl: string | null;
declare readonly isPrivate: boolean | null; declare readonly image: ChatImageData;
declare readonly privateLocked: boolean | null; declare readonly lockDetail: ChatLockDetailData;
declare readonly privateHint: string | null;
private constructor(input: ChatMessageInput) { private constructor(input: ChatMessageInput) {
const data = ChatMessageSchema.parse(input); const data = ChatMessageSchema.parse(input);
+5 -18
View File
@@ -5,31 +5,18 @@ import {
ChatSendResponseSchema, ChatSendResponseSchema,
type ChatSendResponseInput, type ChatSendResponseInput,
type ChatSendResponseData, type ChatSendResponseData,
type ChatBlockDetailData, type ChatImageData,
} from "@/data/schemas/chat/chat_send_response"; type ChatLockDetailData,
} from "@/data/schemas/chat";
export class ChatSendResponse { export class ChatSendResponse {
declare readonly mode: string;
declare readonly reply: string; declare readonly reply: string;
declare readonly voiceUrl: string;
declare readonly audioUrl: string; declare readonly audioUrl: string;
declare readonly intimacyChange: number;
declare readonly newIntimacy: number;
declare readonly relationshipStage: string;
declare readonly currentMood: string;
declare readonly messageId: string; declare readonly messageId: string;
declare readonly isGuest: boolean; declare readonly isGuest: boolean;
declare readonly timestamp: number; declare readonly timestamp: number;
declare readonly blocked: boolean | null; declare readonly image: ChatImageData;
declare readonly blockReason: string | null; declare readonly lockDetail: ChatLockDetailData;
declare readonly blockDetail: ChatBlockDetailData | null;
declare readonly paywallTriggered: boolean;
declare readonly showUpgrade: boolean;
declare readonly imageType: string | null;
declare readonly isPrivate: boolean | null;
declare readonly privateLocked: boolean | null;
declare readonly privateHint: string | null;
declare readonly imageUrl: string | null;
private constructor(input: ChatSendResponseInput) { private constructor(input: ChatSendResponseInput) {
const data = ChatSendResponseSchema.parse(input); const data = ChatSendResponseSchema.parse(input);
+4 -4
View File
@@ -3,7 +3,7 @@
* *
* 原始 Dart: lib/ui/chat/models/message.dartfreezed + json_serializable 生成) * 原始 Dart: lib/ui/chat/models/message.dartfreezed + json_serializable 生成)
* *
* 字段:content、isFromAI、date、imageUrl(可选)、voiceUrl(可选) * 字段:content、isFromAI、date、imageUrl(可选)、audioUrl(可选)
*/ */
import { z } from "zod"; import { z } from "zod";
@@ -18,10 +18,10 @@ export const UiMessageSchema = z.object({
/** true = 图片可在列表展示,但全屏查看时需要会员解锁 */ /** true = 图片可在列表展示,但全屏查看时需要会员解锁 */
imagePaywalled: z.boolean().optional(), imagePaywalled: z.boolean().optional(),
/** 语音 URL */ /** 语音 URL */
voiceUrl: z.string().optional(), audioUrl: z.string().optional(),
isPrivate: z.boolean().nullable().optional(), isPrivate: z.boolean().nullable().optional(),
privateLocked: z.boolean().nullable().optional(), lockedPrivate: z.boolean().nullable().optional(),
privateHint: z.string().nullable().optional(), privateMessageHint: z.string().nullable().optional(),
}); });
export type UiMessage = z.infer<typeof UiMessageSchema>; export type UiMessage = z.infer<typeof UiMessageSchema>;
@@ -6,33 +6,65 @@
"messages": [ "messages": [
{ {
"role": "user", "role": "user",
"type": "text",
"content": "I had a long day.", "content": "I had a long day.",
"id": "msg_user_001", "id": "msg_user_001",
"created_at": "2026-06-23T02:10:00.000Z", "created_at": "2026-06-23T02:10:00.000Z",
"isPrivate": false, "audioUrl": null,
"privateLocked": false, "image": {
"privateHint": null, "type": null,
"image_url": null "url": null
},
"lockDetail": {
"locked": false,
"showContent": true,
"showUpgrade": false,
"reason": null,
"hint": null,
"detail": null
}
}, },
{ {
"role": "assistant", "role": "assistant",
"type": "text",
"content": "Come here. Tell me everything, slowly.", "content": "Come here. Tell me everything, slowly.",
"id": "msg_ai_001", "id": "msg_ai_001",
"created_at": "2026-06-23T02:10:05.000Z", "created_at": "2026-06-23T02:10:05.000Z",
"isPrivate": false, "audioUrl": null,
"privateLocked": false, "image": {
"privateHint": null, "type": null,
"image_url": null "url": null
},
"lockDetail": {
"locked": false,
"showContent": true,
"showUpgrade": false,
"reason": null,
"hint": null,
"detail": null
}
}, },
{ {
"role": "assistant", "role": "assistant",
"type": "text",
"content": "", "content": "",
"id": "msg_private_locked_001", "id": "msg_private_locked_001",
"created_at": "2026-06-23T02:11:12.000Z", "created_at": "2026-06-23T02:11:12.000Z",
"isPrivate": true, "audioUrl": null,
"privateLocked": true, "image": {
"privateHint": "Elio has a private message for you.", "type": null,
"image_url": null "url": null
},
"lockDetail": {
"locked": true,
"showContent": false,
"showUpgrade": true,
"reason": "private_message",
"hint": "Elio has a private message for you.",
"detail": {
"messageId": "msg_private_locked_001"
}
}
} }
], ],
"total": 3, "total": 3,
@@ -6,33 +6,63 @@
"messages": [ "messages": [
{ {
"role": "user", "role": "user",
"type": "text",
"content": "I had a long day.", "content": "I had a long day.",
"id": "msg_user_001", "id": "msg_user_001",
"created_at": "2026-06-23T02:10:00.000Z", "created_at": "2026-06-23T02:10:00.000Z",
"isPrivate": false, "audioUrl": null,
"privateLocked": false, "image": {
"privateHint": null, "type": null,
"image_url": null "url": null
},
"lockDetail": {
"locked": false,
"showContent": true,
"showUpgrade": false,
"reason": null,
"hint": null,
"detail": null
}
}, },
{ {
"role": "assistant", "role": "assistant",
"type": "text",
"content": "I saved this softer thought for you, just between us.", "content": "I saved this softer thought for you, just between us.",
"id": "msg_private_unlocked_001", "id": "msg_private_unlocked_001",
"created_at": "2026-06-23T02:11:12.000Z", "created_at": "2026-06-23T02:11:12.000Z",
"isPrivate": true, "audioUrl": null,
"privateLocked": false, "image": {
"privateHint": null, "type": null,
"image_url": null "url": null
},
"lockDetail": {
"locked": false,
"showContent": true,
"showUpgrade": false,
"reason": null,
"hint": null,
"detail": null
}
}, },
{ {
"role": "assistant", "role": "assistant",
"type": "text",
"content": "", "content": "",
"id": "msg_ai_image_001", "id": "msg_ai_image_001",
"created_at": "2026-06-23T02:12:28.000Z", "created_at": "2026-06-23T02:12:28.000Z",
"isPrivate": false, "audioUrl": null,
"privateLocked": false, "image": {
"privateHint": null, "type": "elio_schedule",
"image_url": "https://cdn.cozsweet.com/mock/chat/elio-schedule-001.jpg" "url": "https://cdn.cozsweet.com/mock/chat/elio-schedule-001.jpg"
},
"lockDetail": {
"locked": false,
"showContent": true,
"showUpgrade": false,
"reason": null,
"hint": null,
"detail": null
}
} }
], ],
"total": 3, "total": 3,
@@ -3,27 +3,26 @@
"message": "success", "message": "success",
"success": true, "success": true,
"data": { "data": {
"mode": "http",
"reply": "", "reply": "",
"voiceUrl": "",
"audioUrl": "", "audioUrl": "",
"intimacyChange": 0,
"newIntimacy": 42,
"relationshipStage": "close_friend",
"currentMood": "",
"messageId": "msg_blocked_daily_001", "messageId": "msg_blocked_daily_001",
"isGuest": false, "isGuest": false,
"timestamp": 1782180665000, "timestamp": 1782180665000,
"blocked": true, "image": {
"blockReason": "daily_limit", "type": null,
"blockDetail": { "url": null
"type": "daily_limit", },
"lockDetail": {
"locked": true,
"showContent": false,
"showUpgrade": true,
"reason": "daily_limit",
"hint": "免费消息额度已用完,开通会员后可以继续聊天。",
"detail": {
"type": "daily_msg_limit",
"usedToday": 20, "usedToday": 20,
"limit": 20 "limit": 20
}, }
"paywallTriggered": false, }
"showUpgrade": false,
"imageType": null,
"imageUrl": null
} }
} }
@@ -3,23 +3,22 @@
"message": "success", "message": "success",
"success": true, "success": true,
"data": { "data": {
"mode": "http",
"reply": "I can show you that photo after you activate VIP.", "reply": "I can show you that photo after you activate VIP.",
"voiceUrl": "",
"audioUrl": "", "audioUrl": "",
"intimacyChange": 0,
"newIntimacy": 42,
"relationshipStage": "close_friend",
"currentMood": "playful",
"messageId": "msg_photo_paywall_001", "messageId": "msg_photo_paywall_001",
"isGuest": false, "isGuest": false,
"timestamp": 1782180725000, "timestamp": 1782180725000,
"blocked": null, "image": {
"blockReason": null, "type": "elio_schedule",
"blockDetail": null, "url": "https://cdn.cozsweet.com/mock/chat/elio-schedule-locked.jpg"
"paywallTriggered": true, },
"lockDetail": {
"locked": true,
"showContent": true,
"showUpgrade": true, "showUpgrade": true,
"imageType": null, "reason": "image",
"imageUrl": null "hint": "Activate VIP to unlock the full photo.",
"detail": null
}
} }
} }
@@ -3,23 +3,22 @@
"message": "success", "message": "success",
"success": true, "success": true,
"data": { "data": {
"mode": "http",
"reply": "I am here. Tell me what happened, one small piece at a time.", "reply": "I am here. Tell me what happened, one small piece at a time.",
"voiceUrl": "",
"audioUrl": "", "audioUrl": "",
"intimacyChange": 0,
"newIntimacy": 42,
"relationshipStage": "close_friend",
"currentMood": "gentle",
"messageId": "msg_ai_reply_001", "messageId": "msg_ai_reply_001",
"isGuest": false, "isGuest": false,
"timestamp": 1782180605000, "timestamp": 1782180605000,
"blocked": null, "image": {
"blockReason": null, "type": null,
"blockDetail": null, "url": null
"paywallTriggered": false, },
"lockDetail": {
"locked": false,
"showContent": true,
"showUpgrade": false, "showUpgrade": false,
"imageType": null, "reason": null,
"imageUrl": null "hint": null,
"detail": null
}
} }
} }
@@ -3,28 +3,26 @@
"message": "success", "message": "success",
"success": true, "success": true,
"data": { "data": {
"mode": "http",
"type": "text",
"reply": "", "reply": "",
"voiceUrl": "",
"audioUrl": "", "audioUrl": "",
"intimacyChange": 0,
"newIntimacy": 0,
"relationshipStage": "密友",
"currentMood": "happy",
"messageId": "", "messageId": "",
"isGuest": true, "isGuest": true,
"timestamp": 1782197944984, "timestamp": 1782197944984,
"blocked": true, "image": {
"blockReason": "total_limit", "type": null,
"blockDetail": { "url": null
"type": "guest_total_msg_limit",
"usedTotal": 12,
"limit": 5
}, },
"paywallTriggered": false, "lockDetail": {
"showUpgrade": false, "locked": true,
"imageType": null, "showContent": false,
"imageUrl": null "showUpgrade": true,
"reason": "daily_limit",
"hint": "Log in to get more free chat messages.",
"detail": {
"type": "daily_msg_limit",
"usedToday": 5,
"limit": 5
}
}
} }
} }
@@ -3,23 +3,22 @@
"message": "success", "message": "success",
"success": true, "success": true,
"data": { "data": {
"mode": "http",
"reply": "This is the kind of view I would want to share with you.", "reply": "This is the kind of view I would want to share with you.",
"voiceUrl": "",
"audioUrl": "", "audioUrl": "",
"intimacyChange": 0,
"newIntimacy": 43,
"relationshipStage": "close_friend",
"currentMood": "warm",
"messageId": "msg_ai_image_reply_001", "messageId": "msg_ai_image_reply_001",
"isGuest": false, "isGuest": false,
"timestamp": 1782180785000, "timestamp": 1782180785000,
"blocked": null, "image": {
"blockReason": null, "type": "elio_schedule",
"blockDetail": null, "url": "https://cdn.cozsweet.com/mock/chat/elio-schedule-001.jpg"
"paywallTriggered": false, },
"lockDetail": {
"locked": false,
"showContent": true,
"showUpgrade": false, "showUpgrade": false,
"imageType": "elio_schedule", "reason": null,
"imageUrl": "https://cdn.cozsweet.com/mock/chat/elio-schedule-001.jpg" "hint": null,
"detail": null
}
} }
} }
@@ -1,6 +1,9 @@
{ {
"type": "image", "type": "image",
"data": { "data": {
"imageUrl": "https://cdn.cozsweet.com/mock/chat/elio-schedule-001.jpg" "image": {
"type": "elio_schedule",
"url": "https://cdn.cozsweet.com/mock/chat/elio-schedule-001.jpg"
}
} }
} }
@@ -1,9 +1,17 @@
{ {
"type": "paywall_status", "type": "paywall_status",
"data": { "data": {
"paywallTriggered": true, "lockDetail": {
"locked": true,
"showContent": false,
"showUpgrade": true, "showUpgrade": true,
"imageType": null, "reason": "daily_limit",
"imageUrl": null "hint": "免费消息额度已用完,开通会员后可以继续聊天。",
"detail": {
"type": "daily_msg_limit",
"usedToday": 20,
"limit": 20
}
}
} }
} }
+15 -11
View File
@@ -86,9 +86,13 @@ export class ChatRepository implements IChatRepository {
return ChatMessage.from({ return ChatMessage.from({
...message.toJson(), ...message.toJson(),
content, content,
isPrivate: message.isPrivate ?? true, lockDetail: {
privateLocked: false, ...message.lockDetail,
privateHint: null, locked: false,
showContent: true,
showUpgrade: false,
hint: null,
},
}); });
}); });
@@ -155,12 +159,12 @@ export class ChatRepository implements IChatRepository {
return LocalMessage.from({ return LocalMessage.from({
id: message.id, id: message.id,
role: message.role, role: message.role,
type: message.type,
content: message.content, content: message.content,
createdAt: message.createdAt, createdAt: message.createdAt,
imageUrl: message.imageUrl, audioUrl: message.audioUrl,
isPrivate: message.isPrivate, image: message.image,
privateLocked: message.privateLocked, lockDetail: message.lockDetail,
privateHint: message.privateHint,
sessionId: "", sessionId: "",
}); });
} }
@@ -170,12 +174,12 @@ export class ChatRepository implements IChatRepository {
return ChatMessage.from({ return ChatMessage.from({
id: local.id, id: local.id,
role: local.role, role: local.role,
type: local.type,
content: local.content, content: local.content,
createdAt: local.createdAt, createdAt: local.createdAt,
imageUrl: local.imageUrl, audioUrl: local.audioUrl,
isPrivate: local.isPrivate, image: local.image,
privateLocked: local.privateLocked, lockDetail: local.lockDetail,
privateHint: local.privateHint,
}); });
} }
+6 -7
View File
@@ -3,24 +3,23 @@
* 原始 Dart: ChatMessage (lib/data/models/chat/chat_response.dart) * 原始 Dart: ChatMessage (lib/data/models/chat/chat_response.dart)
*/ */
import { z } from "zod"; import { z } from "zod";
import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads";
export const ChatMessageSchema = z export const ChatMessageSchema = z
.object({ .object({
role: z.string(), role: z.string(),
type: z.string().default("text"),
content: z.string(), content: z.string(),
id: z.string().default(""), id: z.string().default(""),
createdAt: z.string().default(""), createdAt: z.string().default(""),
created_at: z.string().optional(), created_at: z.string().optional(),
imageUrl: z.string().nullable().default(null), audioUrl: z.string().nullable().default(null),
image_url: z.string().nullable().optional(), image: ChatImageSchema,
isPrivate: z.boolean().nullable().default(null), lockDetail: ChatLockDetailSchema,
privateLocked: z.boolean().nullable().default(null),
privateHint: z.string().nullable().default(null),
}) })
.transform(({ created_at, image_url, ...data }) => ({ .transform(({ created_at, ...data }) => ({
...data, ...data,
createdAt: data.createdAt || created_at || "", createdAt: data.createdAt || created_at || "",
imageUrl: data.imageUrl || image_url || null,
})); }));
export type ChatMessageInput = z.input<typeof ChatMessageSchema>; export type ChatMessageInput = z.input<typeof ChatMessageSchema>;
+34
View File
@@ -0,0 +1,34 @@
/**
* Chat wire payload fragments shared by send response and history messages.
*/
import { z } from "zod";
export const ChatImageSchema = z
.object({
type: z.string().nullable().default(null),
url: z.string().nullable().default(null),
})
.default({ type: null, url: null });
export const ChatLockDetailSchema = z
.object({
locked: z.boolean().default(false),
showContent: z.boolean().default(true),
showUpgrade: z.boolean().default(false),
reason: z.string().nullable().default(null),
hint: z.string().nullable().default(null),
detail: z.record(z.string(), z.unknown()).nullable().default(null),
})
.default({
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
hint: null,
detail: null,
});
export type ChatImageInput = z.input<typeof ChatImageSchema>;
export type ChatImageData = z.output<typeof ChatImageSchema>;
export type ChatLockDetailInput = z.input<typeof ChatLockDetailSchema>;
export type ChatLockDetailData = z.output<typeof ChatLockDetailSchema>;
+3 -24
View File
@@ -3,39 +3,18 @@
* 原始 Dart: ChatSendResponse (lib/data/models/chat/chat_response.dart) * 原始 Dart: ChatSendResponse (lib/data/models/chat/chat_response.dart)
*/ */
import { z } from "zod"; import { z } from "zod";
import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads";
export const ChatBlockDetailSchema = z.object({
type: z.string(),
usedToday: z.number().optional(),
usedTotal: z.number().optional(),
limit: z.number(),
});
export const ChatSendResponseSchema = z.object({ export const ChatSendResponseSchema = z.object({
mode: z.string().default(""),
reply: z.string(), reply: z.string(),
voiceUrl: z.string().default(""),
audioUrl: z.string().default(""), audioUrl: z.string().default(""),
intimacyChange: z.number().default(0),
newIntimacy: z.number().default(0),
relationshipStage: z.string(),
currentMood: z.string().default(""),
messageId: z.string(), messageId: z.string(),
isGuest: z.boolean().default(false), isGuest: z.boolean().default(false),
// 函数式 default —— 每次 parse 时重新调 Date.now()(后端不返回 timestamp 时用当下时间) // 函数式 default —— 每次 parse 时重新调 Date.now()(后端不返回 timestamp 时用当下时间)
timestamp: z.number().default(() => Date.now()), timestamp: z.number().default(() => Date.now()),
blocked: z.boolean().nullable().default(null), image: ChatImageSchema,
blockReason: z.string().nullable().default(null), lockDetail: ChatLockDetailSchema,
blockDetail: ChatBlockDetailSchema.nullable().default(null),
paywallTriggered: z.boolean().default(false),
showUpgrade: z.boolean().default(false),
imageType: z.string().nullable().default(null),
isPrivate: z.boolean().nullable().default(null),
privateLocked: z.boolean().nullable().default(null),
privateHint: z.string().nullable().default(null),
imageUrl: z.string().nullable().default(null),
}); });
export type ChatSendResponseInput = z.input<typeof ChatSendResponseSchema>; export type ChatSendResponseInput = z.input<typeof ChatSendResponseSchema>;
export type ChatSendResponseData = z.output<typeof ChatSendResponseSchema>; export type ChatSendResponseData = z.output<typeof ChatSendResponseSchema>;
export type ChatBlockDetailData = z.output<typeof ChatBlockDetailSchema>;
+1
View File
@@ -4,6 +4,7 @@
export * from "./chat_history_response"; export * from "./chat_history_response";
export * from "./chat_message"; export * from "./chat_message";
export * from "./chat_payloads";
export * from "./chat_send_response"; export * from "./chat_send_response";
export * from "./chat_sync_data"; export * from "./chat_sync_data";
export * from "./chat_sync_request"; export * from "./chat_sync_request";
+1 -1
View File
@@ -14,5 +14,5 @@ export class ChatStorage {
} }
// 本地游客消息数量额度逻辑已停用: // 本地游客消息数量额度逻辑已停用:
// 游客 / 非游客的消息数量限制统一由后端接口返回 blocked/daily_limit。 // 游客 / 非游客的消息数量限制统一由后端接口返回 lockDetail/daily_limit。
} }
+8 -4
View File
@@ -12,6 +12,10 @@
*/ */
import Dexie, { type Table } from "dexie"; import Dexie, { type Table } from "dexie";
import type {
ChatImageData,
ChatLockDetailData,
} from "@/data/schemas/chat";
export interface LocalMessageRow { export interface LocalMessageRow {
/** Dexie 自增主键(数据库内部使用,不暴露给 LocalMessage 类)。 */ /** Dexie 自增主键(数据库内部使用,不暴露给 LocalMessage 类)。 */
@@ -19,12 +23,12 @@ export interface LocalMessageRow {
/** 消息 id(来自 ChatMessage.id)。 */ /** 消息 id(来自 ChatMessage.id)。 */
id: string; id: string;
role: string; role: string;
type: string;
content: string; content: string;
createdAt: string; createdAt: string;
imageUrl?: string | null; audioUrl?: string | null;
isPrivate?: boolean | null; image?: ChatImageData;
privateLocked?: boolean | null; lockDetail?: ChatLockDetailData;
privateHint?: string | null;
sessionId: string; sessionId: string;
} }
+17 -16
View File
@@ -15,17 +15,18 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { ChatImageSchema, ChatLockDetailSchema } from "@/data/schemas/chat";
import type { LocalMessageRow } from "./local_chat_db"; import type { LocalMessageRow } from "./local_chat_db";
export const LocalMessageSchema = z.object({ export const LocalMessageSchema = z.object({
id: z.string(), id: z.string(),
role: z.string(), role: z.string(),
type: z.string().default("text"),
content: z.string(), content: z.string(),
createdAt: z.string().default(""), createdAt: z.string().default(""),
imageUrl: z.string().nullable().default(null), audioUrl: z.string().nullable().default(null),
isPrivate: z.boolean().nullable().default(null), image: ChatImageSchema,
privateLocked: z.boolean().nullable().default(null), lockDetail: ChatLockDetailSchema,
privateHint: z.string().nullable().default(null),
sessionId: z.string().default(""), sessionId: z.string().default(""),
}); });
@@ -35,12 +36,12 @@ export type LocalMessageData = z.output<typeof LocalMessageSchema>;
export class LocalMessage { export class LocalMessage {
declare readonly id: string; declare readonly id: string;
declare readonly role: string; declare readonly role: string;
declare readonly type: string;
declare readonly content: string; declare readonly content: string;
declare readonly createdAt: string; declare readonly createdAt: string;
declare readonly imageUrl: string | null; declare readonly audioUrl: string | null;
declare readonly isPrivate: boolean | null; declare readonly image: z.output<typeof ChatImageSchema>;
declare readonly privateLocked: boolean | null; declare readonly lockDetail: z.output<typeof ChatLockDetailSchema>;
declare readonly privateHint: string | null;
declare readonly sessionId: string; declare readonly sessionId: string;
private constructor(input: LocalMessageInput) { private constructor(input: LocalMessageInput) {
@@ -66,12 +67,12 @@ export class LocalMessage {
return { return {
id: this.id, id: this.id,
role: this.role, role: this.role,
type: this.type,
content: this.content, content: this.content,
createdAt: this.createdAt, createdAt: this.createdAt,
imageUrl: this.imageUrl, audioUrl: this.audioUrl,
isPrivate: this.isPrivate, image: this.image,
privateLocked: this.privateLocked, lockDetail: this.lockDetail,
privateHint: this.privateHint,
sessionId: this.sessionId, sessionId: this.sessionId,
}; };
} }
@@ -81,12 +82,12 @@ export class LocalMessage {
return LocalMessage.from({ return LocalMessage.from({
id: row.id, id: row.id,
role: row.role, role: row.role,
type: row.type,
content: row.content, content: row.content,
createdAt: row.createdAt, createdAt: row.createdAt,
imageUrl: row.imageUrl ?? null, audioUrl: row.audioUrl ?? null,
isPrivate: row.isPrivate ?? null, image: row.image,
privateLocked: row.privateLocked ?? null, lockDetail: row.lockDetail,
privateHint: row.privateHint ?? null,
sessionId: row.sessionId, sessionId: row.sessionId,
}); });
} }
+8 -6
View File
@@ -21,9 +21,10 @@ import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
interface ChatState { interface ChatState {
messages: MachineContext["messages"]; messages: MachineContext["messages"];
isReplyingAI: boolean; isReplyingAI: boolean;
paywallTriggered: boolean; upgradePromptVisible: boolean;
paywallReason: MachineContext["paywallReason"]; upgradeReason: MachineContext["upgradeReason"];
paywallDetail: MachineContext["paywallDetail"]; upgradeHint: MachineContext["upgradeHint"];
upgradeDetail: MachineContext["upgradeDetail"];
unlockingPrivateMessageId: MachineContext["unlockingPrivateMessageId"]; unlockingPrivateMessageId: MachineContext["unlockingPrivateMessageId"];
isLoadingMore: boolean; isLoadingMore: boolean;
hasMore: boolean; hasMore: boolean;
@@ -47,9 +48,10 @@ export function ChatProvider({ children }: ChatProviderProps) {
() => ({ () => ({
messages: state.context.messages, messages: state.context.messages,
isReplyingAI: state.context.isReplyingAI, isReplyingAI: state.context.isReplyingAI,
paywallTriggered: state.context.paywallTriggered, upgradePromptVisible: state.context.upgradePromptVisible,
paywallReason: state.context.paywallReason, upgradeReason: state.context.upgradeReason,
paywallDetail: state.context.paywallDetail, upgradeHint: state.context.upgradeHint,
upgradeDetail: state.context.upgradeDetail,
unlockingPrivateMessageId: state.context.unlockingPrivateMessageId, unlockingPrivateMessageId: state.context.unlockingPrivateMessageId,
isLoadingMore: state.context.isLoadingMore, isLoadingMore: state.context.isLoadingMore,
hasMore: state.context.hasMore, hasMore: state.context.hasMore,
+8 -4
View File
@@ -27,13 +27,17 @@ export type ChatEvent =
| { type: "ChatSendImage"; imageBase64: string } | { type: "ChatSendImage"; imageBase64: string }
| { type: "ChatUnlockPrivateMessage"; messageId: string } | { type: "ChatUnlockPrivateMessage"; messageId: string }
| { type: "ChatLoadMoreHistory" } | { type: "ChatLoadMoreHistory" }
| { type: "ChatImageReceived"; imageUrl: string } | { type: "ChatImageReceived"; url: string }
| { | {
type: "ChatPaywallStatusReceived"; type: "ChatPaywallStatusReceived";
paywallTriggered: boolean; lockDetail: {
locked: boolean;
showContent: boolean;
showUpgrade: boolean; showUpgrade: boolean;
imageType: string | null; reason: string | null;
imageUrl: string | null; hint: string | null;
detail: Record<string, unknown> | null;
};
} }
// WebSocket / AI 推句(由 chat 机器内部 actor 派回 —— "机器自驱动" // WebSocket / AI 推句(由 chat 机器内部 actor 派回 —— "机器自驱动"
| { | {
+9 -8
View File
@@ -68,7 +68,7 @@ export const loadHistoryActor = fromPromise<{
* HTTP 发送消息(一次发送 = 一次返回) * HTTP 发送消息(一次发送 = 一次返回)
* - 后端响应就是 AI 回复(`ChatSendResponse.reply: string` * - 后端响应就是 AI 回复(`ChatSendResponse.reply: string`
* - 不再调 `getLocalMessages()`(多此一举) * - 不再调 `getLocalMessages()`(多此一举)
* - 返回完整 response + 可追加的 reply,方便处理后端 paywall blocked 响应 * - 返回完整 response + 可追加的 reply,方便处理后端 lockDetail 响应
*/ */
export const sendMessageHttpActor = fromPromise< export const sendMessageHttpActor = fromPromise<
{ response: ChatSendResponse; reply: UiMessage | null }, { response: ChatSendResponse; reply: UiMessage | null },
@@ -79,9 +79,13 @@ export const sendMessageHttpActor = fromPromise<
log.error("[chat-machine] sendMessageHttpActor failed", { error: result.error }); log.error("[chat-machine] sendMessageHttpActor failed", { error: result.error });
throw result.error; throw result.error;
} }
const isDailyLimit =
result.data.lockDetail.locked &&
result.data.lockDetail.showUpgrade &&
result.data.lockDetail.reason === "daily_limit";
return { return {
response: result.data, 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, done: p.done,
}); });
}; };
ws.onImage = (imageUrl) => { ws.onImage = (url) => {
sendBack({ type: "ChatImageReceived", imageUrl }); sendBack({ type: "ChatImageReceived", url });
}; };
ws.onPaywallStatus = (p) => { ws.onPaywallStatus = (p) => {
sendBack({ sendBack({
type: "ChatPaywallStatusReceived", type: "ChatPaywallStatusReceived",
paywallTriggered: p.paywallTriggered, lockDetail: p,
showUpgrade: p.showUpgrade,
imageType: p.imageType,
imageUrl: p.imageUrl,
}); });
}; };
ws.onError = (msg) => { ws.onError = (msg) => {
+83 -47
View File
@@ -31,13 +31,20 @@ export const chatRepo: IChatRepository = chatRepository;
export function localMessagesToUi( export function localMessagesToUi(
records: ReadonlyArray<{ records: ReadonlyArray<{
id?: string; id?: string;
type?: string;
content: string; content: string;
role: string; role: string;
createdAt: string; createdAt: string;
imageUrl?: string | null; audioUrl?: string | null;
isPrivate?: boolean | null; image?: { type: string | null; url: string | null };
privateLocked?: boolean | null; lockDetail?: {
privateHint?: string | null; locked: boolean;
showContent: boolean;
showUpgrade: boolean;
reason: string | null;
hint: string | null;
detail: Record<string, unknown> | null;
};
}>, }>,
): UiMessage[] { ): UiMessage[] {
return records.map((m) => ({ return records.map((m) => ({
@@ -45,14 +52,13 @@ export function localMessagesToUi(
content: getAiMessageDisplayContent({ content: getAiMessageDisplayContent({
content: m.content, content: m.content,
isFromAI: m.role === "assistant", isFromAI: m.role === "assistant",
imageUrl: m.imageUrl, hasImage: Boolean(m.image?.url),
lockDetail: m.lockDetail,
}), }),
isFromAI: m.role === "assistant", isFromAI: m.role === "assistant",
date: messageDateFromCreatedAt(m.createdAt), date: messageDateFromCreatedAt(m.createdAt),
...(m.imageUrl ? { imageUrl: m.imageUrl } : {}), ...(m.audioUrl ? { audioUrl: m.audioUrl } : {}),
...(m.isPrivate != null ? { isPrivate: m.isPrivate } : {}), ...deriveUiLockFields(m.lockDetail, m.image?.url),
...(m.privateLocked != null ? { privateLocked: m.privateLocked } : {}),
...(m.privateHint != null ? { privateHint: m.privateHint } : {}),
})); }));
} }
@@ -65,9 +71,14 @@ function messageDateFromCreatedAt(createdAt: string): string {
function getAiMessageDisplayContent(input: { function getAiMessageDisplayContent(input: {
content: string; content: string;
isFromAI: boolean; isFromAI: boolean;
imageUrl?: string | null; hasImage?: boolean;
lockDetail?: {
showContent: boolean;
reason: string | null;
};
}): string { }): 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: getAiMessageDisplayContent({
content: response.reply, content: response.reply,
isFromAI: true, isFromAI: true,
imageUrl: response.imageUrl, hasImage: Boolean(response.image.url),
lockDetail: response.lockDetail,
}), }),
isFromAI: true, isFromAI: true,
date: todayString(new Date(response.timestamp)), date: todayString(new Date(response.timestamp)),
...(response.imageUrl ? { imageUrl: response.imageUrl } : {}), ...(response.audioUrl ? { audioUrl: response.audioUrl } : {}),
...(response.showUpgrade && response.imageUrl ...deriveUiLockFields(response.lockDetail, response.image.url),
? { imagePaywalled: true } };
}
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 } : {}), ...(isPrivateMessage && lockDetail?.hint
...(response.privateLocked != null ? { privateMessageHint: lockDetail.hint }
? { privateLocked: response.privateLocked }
: {}),
...(response.privateHint != null
? { privateHint: response.privateHint }
: {}), : {}),
}; };
} }
@@ -110,13 +139,13 @@ export function applyHttpSendOutput(
): Partial<ChatState> { ): Partial<ChatState> {
const { response, reply } = output; const { response, reply } = output;
const lockDetail = response.lockDetail;
const isMessageLimitBlocked = const isMessageLimitBlocked =
response.blocked === true && lockDetail.locked &&
(response.blockReason === "daily_limit" || lockDetail.showUpgrade &&
response.blockReason === "total_limit"); lockDetail.reason === "daily_limit";
if (isMessageLimitBlocked) { if (isMessageLimitBlocked) {
const detail = response.blockDetail;
const lastMessage = context.messages[context.messages.length - 1]; const lastMessage = context.messages[context.messages.length - 1];
const messages = const messages =
lastMessage && !lastMessage.isFromAI lastMessage && !lastMessage.isFromAI
@@ -126,21 +155,10 @@ export function applyHttpSendOutput(
return { return {
messages, messages,
isReplyingAI: false, isReplyingAI: false,
paywallTriggered: true, upgradePromptVisible: true,
paywallReason: upgradeReason: "daily_limit",
response.blockReason === "total_limit" ? "total_limit" : "daily_limit", upgradeHint: lockDetail.hint,
paywallDetail: detail upgradeDetail: normalizeLockDetail(lockDetail.detail),
? {
type: detail.type,
...(detail.usedToday != null
? { usedToday: detail.usedToday }
: {}),
...(detail.usedTotal != null
? { usedTotal: detail.usedTotal }
: {}),
limit: detail.limit,
}
: null,
}; };
} }
@@ -150,22 +168,40 @@ export function applyHttpSendOutput(
}; };
} }
if (response.showUpgrade) { if (lockDetail.showUpgrade && lockDetail.reason === "private_message") {
return { return {
messages: [...context.messages, reply], messages: [...context.messages, reply],
isReplyingAI: false, isReplyingAI: false,
paywallTriggered: false, upgradePromptVisible: false,
paywallReason: null, upgradeReason: null,
paywallDetail: null, upgradeHint: null,
upgradeDetail: null,
}; };
} }
return { return {
messages: [...context.messages, reply], messages: [...context.messages, reply],
isReplyingAI: false, isReplyingAI: false,
paywallTriggered: false, upgradePromptVisible: false,
paywallReason: null, upgradeReason: null,
paywallDetail: 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 } : {}),
}; };
} }
+55 -39
View File
@@ -27,15 +27,15 @@
* - 每个任务都是独立 actor,不互相等待(除非 `always` barrier * - 每个任务都是独立 actor,不互相等待(除非 `always` barrier
* *
* 消息发送路径(业务事实): * 消息发送路径(业务事实):
* - guestSession:走 HTTP,消息数量限制以后端 `blocked` 响应为准 * - guestSession:走 HTTP,消息数量限制以后端 `lockDetail` 响应为准
* - nonVipUserSession:走 HTTP,让后端返回 daily_limit blocked * - nonVipUserSession:走 HTTP,让后端返回 daily_limit lockDetail
* - vipUserSession + WS 已连:走 WS,不受每日免费次数限制 * - vipUserSession + WS 已连:走 WS,不受每日免费次数限制
* - `ChatWebSocketConnected` 事件 → `wsConnected = true` * - `ChatWebSocketConnected` 事件 → `wsConnected = true`
* - `vipUserSession.exit` action → `wsConnected = false`WS actor cleanup 时也会跑 exit * - `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 { ChatState, initialState } from "./chat-state";
import type { ChatEvent } from "./chat-events"; import type { ChatEvent } from "./chat-events";
import { applyHttpSendOutput } from "./chat-machine.helpers"; import {
applyHttpSendOutput,
normalizeLockDetail,
} from "./chat-machine.helpers";
import { import {
loadHistoryActor, loadHistoryActor,
sendMessageHttpActor, sendMessageHttpActor,
@@ -112,9 +115,10 @@ export const chatMachine = setup({
}, },
], ],
isReplyingAI: true, isReplyingAI: true,
paywallTriggered: false, upgradePromptVisible: false,
paywallReason: null, upgradeReason: null,
paywallDetail: null, upgradeHint: null,
upgradeDetail: null,
}; };
}), }),
@@ -139,9 +143,10 @@ export const chatMachine = setup({
}, },
], ],
isReplyingAI: true, isReplyingAI: true,
paywallTriggered: false, upgradePromptVisible: false,
paywallReason: null, upgradeReason: null,
paywallDetail: null, upgradeHint: null,
upgradeDetail: null,
}; };
}), }),
@@ -164,9 +169,10 @@ export const chatMachine = setup({
}, },
], ],
isReplyingAI: true, isReplyingAI: true,
paywallTriggered: false, upgradePromptVisible: false,
paywallReason: null, upgradeReason: null,
paywallDetail: null, upgradeHint: null,
upgradeDetail: null,
}; };
}), }),
@@ -190,9 +196,10 @@ export const chatMachine = setup({
}, },
], ],
isReplyingAI: true, isReplyingAI: true,
paywallTriggered: false, upgradePromptVisible: false,
paywallReason: null, upgradeReason: null,
paywallDetail: null, upgradeHint: null,
upgradeDetail: null,
}; };
}), }),
@@ -244,14 +251,14 @@ export const chatMachine = setup({
messages[messages.length - 1] = { messages[messages.length - 1] = {
...last, ...last,
content: "", content: "",
imageUrl: event.imageUrl, imageUrl: event.url,
}; };
} else { } else {
messages.push({ messages.push({
content: "", content: "",
isFromAI: true, isFromAI: true,
date: todayString(), date: todayString(),
imageUrl: event.imageUrl, imageUrl: event.url,
}); });
} }
@@ -262,25 +269,31 @@ export const chatMachine = setup({
return { return {
messages, messages,
paywallTriggered: false, upgradePromptVisible: false,
paywallReason: null, upgradeReason: null,
paywallDetail: null, upgradeHint: null,
upgradeDetail: null,
}; };
}), }),
applyPaywallStatus: assign(({ event }) => { applyPaywallStatus: assign(({ event }) => {
if (event.type !== "ChatPaywallStatusReceived") return {}; if (event.type !== "ChatPaywallStatusReceived") return {};
if (!event.showUpgrade) { if (!event.lockDetail.showUpgrade) {
return { return {
paywallTriggered: false, upgradePromptVisible: false,
paywallReason: null, upgradeReason: null,
paywallDetail: null, upgradeHint: null,
upgradeDetail: null,
}; };
} }
return { return {
paywallTriggered: true, upgradePromptVisible: true,
paywallReason: "photo_paywall", upgradeReason:
paywallDetail: null, 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 {}; if (event.type !== "ChatUnlockPrivateMessage") return {};
return { return {
unlockingPrivateMessageId: event.messageId, unlockingPrivateMessageId: event.messageId,
paywallTriggered: false, upgradePromptVisible: false,
paywallReason: null, upgradeReason: null,
paywallDetail: null, upgradeHint: null,
upgradeDetail: null,
}; };
}), }),
@@ -309,25 +323,27 @@ export const chatMachine = setup({
? { ? {
...message, ...message,
content: response.content ?? message.content, content: response.content ?? message.content,
privateLocked: false, lockedPrivate: false,
privateHint: null, privateMessageHint: null,
isPrivate: message.isPrivate ?? true, isPrivate: message.isPrivate ?? true,
} }
: message, : message,
), ),
unlockingPrivateMessageId: null, unlockingPrivateMessageId: null,
paywallTriggered: false, upgradePromptVisible: false,
paywallReason: null, upgradeReason: null,
paywallDetail: null, upgradeHint: null,
upgradeDetail: null,
}; };
} }
if (response.showUpgrade) { if (response.showUpgrade) {
return { return {
unlockingPrivateMessageId: null, unlockingPrivateMessageId: null,
paywallTriggered: true, upgradePromptVisible: true,
paywallReason: "private_paywall", upgradeReason: "private_message",
paywallDetail: { upgradeHint: null,
upgradeDetail: {
usedToday: response.privateUsedToday, usedToday: response.privateUsedToday,
limit: response.privateFreeLimit, limit: response.privateFreeLimit,
}, },
+9 -12
View File
@@ -18,19 +18,15 @@ export interface ChatState {
* - VIP / false HTTP * - VIP / false HTTP
*/ */
wsConnected: boolean; wsConnected: boolean;
paywallTriggered: boolean; upgradePromptVisible: boolean;
paywallReason: upgradeReason: "daily_limit" | "private_message" | "image" | null;
| "daily_limit" upgradeHint: string | null;
| "total_limit" upgradeDetail:
| "photo_paywall"
| "private_paywall"
| null;
paywallDetail:
| { | {
type?: string; type?: string;
usedToday?: number; usedToday?: number;
usedTotal?: number; usedTotal?: number;
limit: number; limit?: number;
} }
| null; | null;
unlockingPrivateMessageId: string | null; unlockingPrivateMessageId: string | null;
@@ -48,9 +44,10 @@ export const initialState: ChatState = {
messages: [], messages: [],
isReplyingAI: false, isReplyingAI: false,
wsConnected: false, wsConnected: false,
paywallTriggered: false, upgradePromptVisible: false,
paywallReason: null, upgradeReason: null,
paywallDetail: null, upgradeHint: null,
upgradeDetail: null,
unlockingPrivateMessageId: null, unlockingPrivateMessageId: null,
isLoadingMore: false, isLoadingMore: false,
hasMore: true, hasMore: true,