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