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,