diff --git a/docs-change.md b/docs-change.md new file mode 100644 index 00000000..980b55e3 --- /dev/null +++ b/docs-change.md @@ -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。 \ No newline at end of file diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index eee3b8d8..61294ea9 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -45,19 +45,15 @@ export function ChatScreen() { // isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段) const isGuest = deriveIsGuest(authState.loginStatus); - // 消息数量限制由后端统一返回 blocked,前端不再处理本地额度。 + // 消息数量限制由后端统一返回 lockDetail,前端不再处理本地额度。 const showMessageLimitBanner = - state.paywallTriggered && - (state.paywallReason === "daily_limit" || - state.paywallReason === "total_limit"); + state.upgradePromptVisible && state.upgradeReason === "daily_limit"; const messageLimitTitle = - state.paywallReason === "total_limit" - ? "The limit for free chat times\nhas been reached" - : undefined; + state.upgradeHint ?? "The limit for free chat times\nhas been reached"; const messageLimitCta = isGuest ? "Log in to continue chatting" : undefined; const showPrivatePaywallBanner = - state.paywallTriggered && state.paywallReason === "private_paywall"; + state.upgradePromptVisible && state.upgradeReason === "private_message"; const inlineUpgradeTitle = "Unlock VIP to view private messages"; const inlineUpgradeCta = "Activate VIP to view private messages"; diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index 0c2e8f22..df68c514 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -95,8 +95,8 @@ function renderMessagesWithDateHeaders( imageUrl={item.message.imageUrl} imagePaywalled={item.message.imagePaywalled} isFromAI={item.message.isFromAI} - privateLocked={item.message.privateLocked} - privateHint={item.message.privateHint} + lockedPrivate={item.message.lockedPrivate} + privateMessageHint={item.message.privateMessageHint} isUnlockingPrivate={ item.message.id != null && item.message.id === unlockingPrivateMessageId diff --git a/src/app/chat/components/chat-quota-exhausted-banner.tsx b/src/app/chat/components/chat-quota-exhausted-banner.tsx index 5196d93c..36d16c06 100644 --- a/src/app/chat/components/chat-quota-exhausted-banner.tsx +++ b/src/app/chat/components/chat-quota-exhausted-banner.tsx @@ -3,7 +3,7 @@ * ChatQuotaExhaustedBanner 游客配额耗尽横幅 * * 何时显示: - * - 后端返回 blocked=true && blockReason="daily_limit" + * - 后端返回 lockDetail.reason="daily_limit" 且 showUpgrade=true * * 视觉规格(与设计稿对齐): * - 粉→品红渐变背景(与 splash 渐变一致) diff --git a/src/app/chat/components/message-bubble.tsx b/src/app/chat/components/message-bubble.tsx index 7940543b..c1a43bb7 100644 --- a/src/app/chat/components/message-bubble.tsx +++ b/src/app/chat/components/message-bubble.tsx @@ -20,8 +20,8 @@ export interface MessageBubbleProps { imageUrl?: string | null; imagePaywalled?: boolean; isFromAI: boolean; - privateLocked?: boolean | null; - privateHint?: string | null; + lockedPrivate?: boolean | null; + privateMessageHint?: string | null; isUnlockingPrivate?: boolean; onUnlockPrivateMessage?: (messageId: string) => void; onUnlockImagePaywall?: () => void; @@ -33,8 +33,8 @@ export function MessageBubble({ imageUrl, imagePaywalled, isFromAI, - privateLocked, - privateHint, + lockedPrivate, + privateMessageHint, isUnlockingPrivate, onUnlockPrivateMessage, onUnlockImagePaywall, @@ -52,8 +52,8 @@ export function MessageBubble({ imageUrl={imageUrl} imagePaywalled={imagePaywalled} isFromAI={true} - privateLocked={privateLocked} - privateHint={privateHint} + lockedPrivate={lockedPrivate} + privateMessageHint={privateMessageHint} isUnlockingPrivate={isUnlockingPrivate} onUnlockPrivateMessage={onUnlockPrivateMessage} onUnlockImagePaywall={onUnlockImagePaywall} @@ -72,8 +72,8 @@ export function MessageBubble({ imageUrl={imageUrl} imagePaywalled={imagePaywalled} isFromAI={false} - privateLocked={privateLocked} - privateHint={privateHint} + lockedPrivate={lockedPrivate} + privateMessageHint={privateMessageHint} isUnlockingPrivate={isUnlockingPrivate} onUnlockPrivateMessage={onUnlockPrivateMessage} onUnlockImagePaywall={onUnlockImagePaywall} diff --git a/src/app/chat/components/message-content.tsx b/src/app/chat/components/message-content.tsx index 453a6093..f13ad552 100644 --- a/src/app/chat/components/message-content.tsx +++ b/src/app/chat/components/message-content.tsx @@ -17,8 +17,8 @@ export interface MessageContentProps { imageUrl?: string | null; imagePaywalled?: boolean; isFromAI: boolean; - privateLocked?: boolean | null; - privateHint?: string | null; + lockedPrivate?: boolean | null; + privateMessageHint?: string | null; isUnlockingPrivate?: boolean; onUnlockPrivateMessage?: (messageId: string) => void; onUnlockImagePaywall?: () => void; @@ -32,15 +32,15 @@ export function MessageContent({ imageUrl, imagePaywalled, isFromAI, - privateLocked, - privateHint, + lockedPrivate, + privateMessageHint, isUnlockingPrivate = false, onUnlockPrivateMessage, onUnlockImagePaywall, }: MessageContentProps) { const hasImage = imageUrl != null && imageUrl.length > 0; const hasText = content.length > 0 && content !== IMAGE_PLACEHOLDER; - const isLockedPrivateMessage = privateLocked === true; + const isLockedPrivateMessage = lockedPrivate === true; return (
{isLockedPrivateMessage ? ( | null; } export class ChatWebSocket { @@ -46,10 +40,8 @@ export class ChatWebSocket { onTyping: ((isTyping: boolean) => void) | null = null; onSentence: ((payload: SentencePayload) => 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; - onIntimacyUpdate: ((payload: IntimacyPayload) => void) | null = null; - onMoodUpdate: ((mood: string) => void) | null = null; onError: ((errorMessage: string) => void) | null = null; constructor( @@ -141,16 +133,13 @@ export class ChatWebSocket { total?: number; done?: boolean; audioUrl?: string; - intimacyChange?: number; - newIntimacy?: number; - relationshipStage?: string; - mood?: string; error?: string; data?: { - imageUrl?: string | null; - paywallTriggered?: boolean; - showUpgrade?: boolean; - imageType?: string | null; + image?: { + type?: string | null; + url?: string | null; + }; + lockDetail?: Partial; }; }; try { @@ -178,28 +167,22 @@ export class ChatWebSocket { this.onVoiceReady?.(payload.index ?? 0, payload.audioUrl ?? ""); break; case "image": { - const imageUrl = payload.data?.imageUrl; - if (imageUrl) this.onImage?.(imageUrl); + const url = payload.data?.image?.url; + if (url) this.onImage?.(url); break; } - case "paywall_status": + case "paywall_status": { + const lockDetail = payload.data?.lockDetail; this.onPaywallStatus?.({ - paywallTriggered: payload.data?.paywallTriggered ?? false, - showUpgrade: payload.data?.showUpgrade ?? false, - imageType: payload.data?.imageType ?? null, - imageUrl: payload.data?.imageUrl ?? null, + locked: lockDetail?.locked ?? false, + showContent: lockDetail?.showContent ?? true, + showUpgrade: lockDetail?.showUpgrade ?? false, + reason: lockDetail?.reason ?? null, + hint: lockDetail?.hint ?? null, + detail: lockDetail?.detail ?? null, }); 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": this.onError?.(payload.error ?? "Unknown error"); break; diff --git a/src/data/dto/chat/chat_message.ts b/src/data/dto/chat/chat_message.ts index 9285b3eb..57a1fbe6 100644 --- a/src/data/dto/chat/chat_message.ts +++ b/src/data/dto/chat/chat_message.ts @@ -5,17 +5,19 @@ import { ChatMessageSchema, type ChatMessageInput, type ChatMessageData, -} from "@/data/schemas/chat/chat_message"; + type ChatImageData, + type ChatLockDetailData, +} from "@/data/schemas/chat"; export class ChatMessage { declare readonly role: string; + declare readonly type: string; declare readonly content: string; declare readonly id: string; declare readonly createdAt: string; - declare readonly imageUrl: string | null; - declare readonly isPrivate: boolean | null; - declare readonly privateLocked: boolean | null; - declare readonly privateHint: string | null; + declare readonly audioUrl: string | null; + declare readonly image: ChatImageData; + declare readonly lockDetail: ChatLockDetailData; private constructor(input: ChatMessageInput) { const data = ChatMessageSchema.parse(input); diff --git a/src/data/dto/chat/chat_send_response.ts b/src/data/dto/chat/chat_send_response.ts index cf8a7479..69269cf5 100644 --- a/src/data/dto/chat/chat_send_response.ts +++ b/src/data/dto/chat/chat_send_response.ts @@ -5,31 +5,18 @@ import { ChatSendResponseSchema, type ChatSendResponseInput, type ChatSendResponseData, - type ChatBlockDetailData, -} from "@/data/schemas/chat/chat_send_response"; + type ChatImageData, + type ChatLockDetailData, +} from "@/data/schemas/chat"; export class ChatSendResponse { - declare readonly mode: string; declare readonly reply: string; - declare readonly voiceUrl: 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 isGuest: boolean; declare readonly timestamp: number; - declare readonly blocked: boolean | null; - declare readonly blockReason: string | null; - 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; + declare readonly image: ChatImageData; + declare readonly lockDetail: ChatLockDetailData; private constructor(input: ChatSendResponseInput) { const data = ChatSendResponseSchema.parse(input); diff --git a/src/data/dto/chat/ui_message.ts b/src/data/dto/chat/ui_message.ts index 1695e4a8..9820ba11 100644 --- a/src/data/dto/chat/ui_message.ts +++ b/src/data/dto/chat/ui_message.ts @@ -3,7 +3,7 @@ * * 原始 Dart: lib/ui/chat/models/message.dart(freezed + json_serializable 生成) * - * 字段:content、isFromAI、date、imageUrl(可选)、voiceUrl(可选) + * 字段:content、isFromAI、date、imageUrl(可选)、audioUrl(可选) */ import { z } from "zod"; @@ -18,10 +18,10 @@ export const UiMessageSchema = z.object({ /** true = 图片可在列表展示,但全屏查看时需要会员解锁 */ imagePaywalled: z.boolean().optional(), /** 语音 URL */ - voiceUrl: z.string().optional(), + audioUrl: z.string().optional(), isPrivate: z.boolean().nullable().optional(), - privateLocked: z.boolean().nullable().optional(), - privateHint: z.string().nullable().optional(), + lockedPrivate: z.boolean().nullable().optional(), + privateMessageHint: z.string().nullable().optional(), }); export type UiMessage = z.infer; diff --git a/src/data/mock/chat/responses/history-non-vip-with-locked-private-message.json b/src/data/mock/chat/responses/history-non-vip-with-locked-private-message.json index ddf30d54..725f5abd 100644 --- a/src/data/mock/chat/responses/history-non-vip-with-locked-private-message.json +++ b/src/data/mock/chat/responses/history-non-vip-with-locked-private-message.json @@ -6,33 +6,65 @@ "messages": [ { "role": "user", + "type": "text", "content": "I had a long day.", "id": "msg_user_001", "created_at": "2026-06-23T02:10:00.000Z", - "isPrivate": false, - "privateLocked": false, - "privateHint": null, - "image_url": null + "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": "Come here. Tell me everything, slowly.", "id": "msg_ai_001", "created_at": "2026-06-23T02:10:05.000Z", - "isPrivate": false, - "privateLocked": false, - "privateHint": null, - "image_url": null + "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_private_locked_001", "created_at": "2026-06-23T02:11:12.000Z", - "isPrivate": true, - "privateLocked": true, - "privateHint": "Elio has a private message for you.", - "image_url": null + "audioUrl": null, + "image": { + "type": 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, diff --git a/src/data/mock/chat/responses/history-vip-with-unlocked-private-message.json b/src/data/mock/chat/responses/history-vip-with-unlocked-private-message.json index 3088af64..aa61eb23 100644 --- a/src/data/mock/chat/responses/history-vip-with-unlocked-private-message.json +++ b/src/data/mock/chat/responses/history-vip-with-unlocked-private-message.json @@ -6,33 +6,63 @@ "messages": [ { "role": "user", + "type": "text", "content": "I had a long day.", "id": "msg_user_001", "created_at": "2026-06-23T02:10:00.000Z", - "isPrivate": false, - "privateLocked": false, - "privateHint": null, - "image_url": null + "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": "I saved this softer thought for you, just between us.", "id": "msg_private_unlocked_001", "created_at": "2026-06-23T02:11:12.000Z", - "isPrivate": true, - "privateLocked": false, - "privateHint": null, - "image_url": null + "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_ai_image_001", "created_at": "2026-06-23T02:12:28.000Z", - "isPrivate": false, - "privateLocked": false, - "privateHint": null, - "image_url": "https://cdn.cozsweet.com/mock/chat/elio-schedule-001.jpg" + "audioUrl": null, + "image": { + "type": "elio_schedule", + "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, diff --git a/src/data/mock/chat/responses/send-message-daily-limit-blocked.json b/src/data/mock/chat/responses/send-message-daily-limit-blocked.json index c2e151e6..7948c06f 100644 --- a/src/data/mock/chat/responses/send-message-daily-limit-blocked.json +++ b/src/data/mock/chat/responses/send-message-daily-limit-blocked.json @@ -3,27 +3,26 @@ "message": "success", "success": true, "data": { - "mode": "http", "reply": "", - "voiceUrl": "", "audioUrl": "", - "intimacyChange": 0, - "newIntimacy": 42, - "relationshipStage": "close_friend", - "currentMood": "", "messageId": "msg_blocked_daily_001", "isGuest": false, "timestamp": 1782180665000, - "blocked": true, - "blockReason": "daily_limit", - "blockDetail": { - "type": "daily_limit", - "usedToday": 20, - "limit": 20 + "image": { + "type": null, + "url": null }, - "paywallTriggered": false, - "showUpgrade": false, - "imageType": null, - "imageUrl": null + "lockDetail": { + "locked": true, + "showContent": false, + "showUpgrade": true, + "reason": "daily_limit", + "hint": "免费消息额度已用完,开通会员后可以继续聊天。", + "detail": { + "type": "daily_msg_limit", + "usedToday": 20, + "limit": 20 + } + } } } diff --git a/src/data/mock/chat/responses/send-message-photo-paywall.json b/src/data/mock/chat/responses/send-message-photo-paywall.json index 68952f44..0a8b25a8 100644 --- a/src/data/mock/chat/responses/send-message-photo-paywall.json +++ b/src/data/mock/chat/responses/send-message-photo-paywall.json @@ -3,23 +3,22 @@ "message": "success", "success": true, "data": { - "mode": "http", "reply": "I can show you that photo after you activate VIP.", - "voiceUrl": "", "audioUrl": "", - "intimacyChange": 0, - "newIntimacy": 42, - "relationshipStage": "close_friend", - "currentMood": "playful", "messageId": "msg_photo_paywall_001", "isGuest": false, "timestamp": 1782180725000, - "blocked": null, - "blockReason": null, - "blockDetail": null, - "paywallTriggered": true, - "showUpgrade": true, - "imageType": null, - "imageUrl": null + "image": { + "type": "elio_schedule", + "url": "https://cdn.cozsweet.com/mock/chat/elio-schedule-locked.jpg" + }, + "lockDetail": { + "locked": true, + "showContent": true, + "showUpgrade": true, + "reason": "image", + "hint": "Activate VIP to unlock the full photo.", + "detail": null + } } } diff --git a/src/data/mock/chat/responses/send-message-success.json b/src/data/mock/chat/responses/send-message-success.json index 04a70917..57ad47f0 100644 --- a/src/data/mock/chat/responses/send-message-success.json +++ b/src/data/mock/chat/responses/send-message-success.json @@ -3,23 +3,22 @@ "message": "success", "success": true, "data": { - "mode": "http", "reply": "I am here. Tell me what happened, one small piece at a time.", - "voiceUrl": "", "audioUrl": "", - "intimacyChange": 0, - "newIntimacy": 42, - "relationshipStage": "close_friend", - "currentMood": "gentle", "messageId": "msg_ai_reply_001", "isGuest": false, "timestamp": 1782180605000, - "blocked": null, - "blockReason": null, - "blockDetail": null, - "paywallTriggered": false, - "showUpgrade": false, - "imageType": null, - "imageUrl": null + "image": { + "type": null, + "url": null + }, + "lockDetail": { + "locked": false, + "showContent": true, + "showUpgrade": false, + "reason": null, + "hint": null, + "detail": null + } } } diff --git a/src/data/mock/chat/responses/send-message-total-limit-blocked.json b/src/data/mock/chat/responses/send-message-total-limit-blocked.json index 348f8e6d..bd1359f0 100644 --- a/src/data/mock/chat/responses/send-message-total-limit-blocked.json +++ b/src/data/mock/chat/responses/send-message-total-limit-blocked.json @@ -3,28 +3,26 @@ "message": "success", "success": true, "data": { - "mode": "http", - "type": "text", "reply": "", - "voiceUrl": "", "audioUrl": "", - "intimacyChange": 0, - "newIntimacy": 0, - "relationshipStage": "密友", - "currentMood": "happy", "messageId": "", "isGuest": true, "timestamp": 1782197944984, - "blocked": true, - "blockReason": "total_limit", - "blockDetail": { - "type": "guest_total_msg_limit", - "usedTotal": 12, - "limit": 5 + "image": { + "type": null, + "url": null }, - "paywallTriggered": false, - "showUpgrade": false, - "imageType": null, - "imageUrl": null + "lockDetail": { + "locked": true, + "showContent": false, + "showUpgrade": true, + "reason": "daily_limit", + "hint": "Log in to get more free chat messages.", + "detail": { + "type": "daily_msg_limit", + "usedToday": 5, + "limit": 5 + } + } } } diff --git a/src/data/mock/chat/responses/send-message-with-image.json b/src/data/mock/chat/responses/send-message-with-image.json index f382c30f..7786335f 100644 --- a/src/data/mock/chat/responses/send-message-with-image.json +++ b/src/data/mock/chat/responses/send-message-with-image.json @@ -3,23 +3,22 @@ "message": "success", "success": true, "data": { - "mode": "http", "reply": "This is the kind of view I would want to share with you.", - "voiceUrl": "", "audioUrl": "", - "intimacyChange": 0, - "newIntimacy": 43, - "relationshipStage": "close_friend", - "currentMood": "warm", "messageId": "msg_ai_image_reply_001", "isGuest": false, "timestamp": 1782180785000, - "blocked": null, - "blockReason": null, - "blockDetail": null, - "paywallTriggered": false, - "showUpgrade": false, - "imageType": "elio_schedule", - "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" + }, + "lockDetail": { + "locked": false, + "showContent": true, + "showUpgrade": false, + "reason": null, + "hint": null, + "detail": null + } } } diff --git a/src/data/mock/chat/websocket-events/image.json b/src/data/mock/chat/websocket-events/image.json index 79e9fa6d..42b393b8 100644 --- a/src/data/mock/chat/websocket-events/image.json +++ b/src/data/mock/chat/websocket-events/image.json @@ -1,6 +1,9 @@ { "type": "image", "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" + } } } diff --git a/src/data/mock/chat/websocket-events/paywall-status.json b/src/data/mock/chat/websocket-events/paywall-status.json index d2681c09..f00ce8a2 100644 --- a/src/data/mock/chat/websocket-events/paywall-status.json +++ b/src/data/mock/chat/websocket-events/paywall-status.json @@ -1,9 +1,17 @@ { "type": "paywall_status", "data": { - "paywallTriggered": true, - "showUpgrade": true, - "imageType": null, - "imageUrl": null + "lockDetail": { + "locked": true, + "showContent": false, + "showUpgrade": true, + "reason": "daily_limit", + "hint": "免费消息额度已用完,开通会员后可以继续聊天。", + "detail": { + "type": "daily_msg_limit", + "usedToday": 20, + "limit": 20 + } + } } } diff --git a/src/data/repositories/chat_repository.ts b/src/data/repositories/chat_repository.ts index 76fe0f5e..cfff2c38 100644 --- a/src/data/repositories/chat_repository.ts +++ b/src/data/repositories/chat_repository.ts @@ -86,9 +86,13 @@ export class ChatRepository implements IChatRepository { return ChatMessage.from({ ...message.toJson(), content, - isPrivate: message.isPrivate ?? true, - privateLocked: false, - privateHint: null, + lockDetail: { + ...message.lockDetail, + locked: false, + showContent: true, + showUpgrade: false, + hint: null, + }, }); }); @@ -155,12 +159,12 @@ export class ChatRepository implements IChatRepository { return LocalMessage.from({ id: message.id, role: message.role, + type: message.type, content: message.content, createdAt: message.createdAt, - imageUrl: message.imageUrl, - isPrivate: message.isPrivate, - privateLocked: message.privateLocked, - privateHint: message.privateHint, + audioUrl: message.audioUrl, + image: message.image, + lockDetail: message.lockDetail, sessionId: "", }); } @@ -170,12 +174,12 @@ export class ChatRepository implements IChatRepository { return ChatMessage.from({ id: local.id, role: local.role, + type: local.type, content: local.content, createdAt: local.createdAt, - imageUrl: local.imageUrl, - isPrivate: local.isPrivate, - privateLocked: local.privateLocked, - privateHint: local.privateHint, + audioUrl: local.audioUrl, + image: local.image, + lockDetail: local.lockDetail, }); } diff --git a/src/data/schemas/chat/chat_message.ts b/src/data/schemas/chat/chat_message.ts index 030b97d6..e4c6d912 100644 --- a/src/data/schemas/chat/chat_message.ts +++ b/src/data/schemas/chat/chat_message.ts @@ -3,24 +3,23 @@ * 原始 Dart: ChatMessage (lib/data/models/chat/chat_response.dart) */ import { z } from "zod"; +import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads"; export const ChatMessageSchema = z .object({ role: z.string(), + type: z.string().default("text"), content: z.string(), id: z.string().default(""), createdAt: z.string().default(""), created_at: z.string().optional(), - imageUrl: z.string().nullable().default(null), - image_url: z.string().nullable().optional(), - isPrivate: z.boolean().nullable().default(null), - privateLocked: z.boolean().nullable().default(null), - privateHint: z.string().nullable().default(null), + audioUrl: z.string().nullable().default(null), + image: ChatImageSchema, + lockDetail: ChatLockDetailSchema, }) - .transform(({ created_at, image_url, ...data }) => ({ + .transform(({ created_at, ...data }) => ({ ...data, createdAt: data.createdAt || created_at || "", - imageUrl: data.imageUrl || image_url || null, })); export type ChatMessageInput = z.input; diff --git a/src/data/schemas/chat/chat_payloads.ts b/src/data/schemas/chat/chat_payloads.ts new file mode 100644 index 00000000..edc9d868 --- /dev/null +++ b/src/data/schemas/chat/chat_payloads.ts @@ -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; +export type ChatImageData = z.output; +export type ChatLockDetailInput = z.input; +export type ChatLockDetailData = z.output; diff --git a/src/data/schemas/chat/chat_send_response.ts b/src/data/schemas/chat/chat_send_response.ts index 962c975a..c68e0a83 100644 --- a/src/data/schemas/chat/chat_send_response.ts +++ b/src/data/schemas/chat/chat_send_response.ts @@ -3,39 +3,18 @@ * 原始 Dart: ChatSendResponse (lib/data/models/chat/chat_response.dart) */ import { z } from "zod"; - -export const ChatBlockDetailSchema = z.object({ - type: z.string(), - usedToday: z.number().optional(), - usedTotal: z.number().optional(), - limit: z.number(), -}); +import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads"; export const ChatSendResponseSchema = z.object({ - mode: z.string().default(""), reply: z.string(), - voiceUrl: 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(), isGuest: z.boolean().default(false), // 函数式 default —— 每次 parse 时重新调 Date.now()(后端不返回 timestamp 时用当下时间) timestamp: z.number().default(() => Date.now()), - blocked: z.boolean().nullable().default(null), - blockReason: z.string().nullable().default(null), - 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), + image: ChatImageSchema, + lockDetail: ChatLockDetailSchema, }); export type ChatSendResponseInput = z.input; export type ChatSendResponseData = z.output; -export type ChatBlockDetailData = z.output; diff --git a/src/data/schemas/chat/index.ts b/src/data/schemas/chat/index.ts index a7d86ba3..4d6f28c5 100644 --- a/src/data/schemas/chat/index.ts +++ b/src/data/schemas/chat/index.ts @@ -4,6 +4,7 @@ export * from "./chat_history_response"; export * from "./chat_message"; +export * from "./chat_payloads"; export * from "./chat_send_response"; export * from "./chat_sync_data"; export * from "./chat_sync_request"; diff --git a/src/data/storage/chat/chat_storage.ts b/src/data/storage/chat/chat_storage.ts index 892ab934..98e9bb09 100644 --- a/src/data/storage/chat/chat_storage.ts +++ b/src/data/storage/chat/chat_storage.ts @@ -14,5 +14,5 @@ export class ChatStorage { } // 本地游客消息数量额度逻辑已停用: - // 游客 / 非游客的消息数量限制统一由后端接口返回 blocked/daily_limit。 + // 游客 / 非游客的消息数量限制统一由后端接口返回 lockDetail/daily_limit。 } diff --git a/src/data/storage/chat/local_chat_db.ts b/src/data/storage/chat/local_chat_db.ts index f4a245af..ad7dc626 100644 --- a/src/data/storage/chat/local_chat_db.ts +++ b/src/data/storage/chat/local_chat_db.ts @@ -12,6 +12,10 @@ */ import Dexie, { type Table } from "dexie"; +import type { + ChatImageData, + ChatLockDetailData, +} from "@/data/schemas/chat"; export interface LocalMessageRow { /** Dexie 自增主键(数据库内部使用,不暴露给 LocalMessage 类)。 */ @@ -19,12 +23,12 @@ export interface LocalMessageRow { /** 消息 id(来自 ChatMessage.id)。 */ id: string; role: string; + type: string; content: string; createdAt: string; - imageUrl?: string | null; - isPrivate?: boolean | null; - privateLocked?: boolean | null; - privateHint?: string | null; + audioUrl?: string | null; + image?: ChatImageData; + lockDetail?: ChatLockDetailData; sessionId: string; } diff --git a/src/data/storage/chat/local_message.ts b/src/data/storage/chat/local_message.ts index de18ea4a..f56d6fde 100644 --- a/src/data/storage/chat/local_message.ts +++ b/src/data/storage/chat/local_message.ts @@ -15,17 +15,18 @@ */ import { z } from "zod"; +import { ChatImageSchema, ChatLockDetailSchema } from "@/data/schemas/chat"; import type { LocalMessageRow } from "./local_chat_db"; export const LocalMessageSchema = z.object({ id: z.string(), role: z.string(), + type: z.string().default("text"), content: z.string(), createdAt: z.string().default(""), - imageUrl: z.string().nullable().default(null), - isPrivate: z.boolean().nullable().default(null), - privateLocked: z.boolean().nullable().default(null), - privateHint: z.string().nullable().default(null), + audioUrl: z.string().nullable().default(null), + image: ChatImageSchema, + lockDetail: ChatLockDetailSchema, sessionId: z.string().default(""), }); @@ -35,12 +36,12 @@ export type LocalMessageData = z.output; export class LocalMessage { declare readonly id: string; declare readonly role: string; + declare readonly type: string; declare readonly content: string; declare readonly createdAt: string; - declare readonly imageUrl: string | null; - declare readonly isPrivate: boolean | null; - declare readonly privateLocked: boolean | null; - declare readonly privateHint: string | null; + declare readonly audioUrl: string | null; + declare readonly image: z.output; + declare readonly lockDetail: z.output; declare readonly sessionId: string; private constructor(input: LocalMessageInput) { @@ -66,12 +67,12 @@ export class LocalMessage { return { id: this.id, role: this.role, + type: this.type, content: this.content, createdAt: this.createdAt, - imageUrl: this.imageUrl, - isPrivate: this.isPrivate, - privateLocked: this.privateLocked, - privateHint: this.privateHint, + audioUrl: this.audioUrl, + image: this.image, + lockDetail: this.lockDetail, sessionId: this.sessionId, }; } @@ -81,12 +82,12 @@ export class LocalMessage { return LocalMessage.from({ id: row.id, role: row.role, + type: row.type, content: row.content, createdAt: row.createdAt, - imageUrl: row.imageUrl ?? null, - isPrivate: row.isPrivate ?? null, - privateLocked: row.privateLocked ?? null, - privateHint: row.privateHint ?? null, + audioUrl: row.audioUrl ?? null, + image: row.image, + lockDetail: row.lockDetail, sessionId: row.sessionId, }); } diff --git a/src/stores/chat/chat-context.tsx b/src/stores/chat/chat-context.tsx index a7c4ac75..114595ab 100644 --- a/src/stores/chat/chat-context.tsx +++ b/src/stores/chat/chat-context.tsx @@ -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, diff --git a/src/stores/chat/chat-events.ts b/src/stores/chat/chat-events.ts index 48c61931..b8e73384 100644 --- a/src/stores/chat/chat-events.ts +++ b/src/stores/chat/chat-events.ts @@ -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 | null; + }; } // WebSocket / AI 推句(由 chat 机器内部 actor 派回 —— "机器自驱动") | { diff --git a/src/stores/chat/chat-machine.actors.ts b/src/stores/chat/chat-machine.actors.ts index f2ce9e9b..ad60ee60 100644 --- a/src/stores/chat/chat-machine.actors.ts +++ b/src/stores/chat/chat-machine.actors.ts @@ -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( 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) => { diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts index 2397df91..04eb0dc5 100644 --- a/src/stores/chat/chat-machine.helpers.ts +++ b/src/stores/chat/chat-machine.helpers.ts @@ -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 | 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 { + 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 { 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 | 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 } : {}), }; } diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index 3304f6ab..0467dbd0 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -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, }, diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts index 69c3d2d0..dc2197d1 100644 --- a/src/stores/chat/chat-state.ts +++ b/src/stores/chat/chat-state.ts @@ -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,