feat(chat): add private message unlock flow

This commit is contained in:
2026-06-23 13:21:57 +08:00
parent c9d75b6fe6
commit ebd44c4980
20 changed files with 455 additions and 29 deletions
+2 -2
View File
@@ -3,8 +3,8 @@ NEXT_PUBLIC_APP_ENV=test
# NextAuth v4 —— OAuth callback **公**网 base URL**必**须与**公**网域名**一**致) # NextAuth v4 —— OAuth callback **公**网 base URL**必**须与**公**网域名**一**致)
NEXTAUTH_URL=https://frontend-test.banlv-ai.com NEXTAUTH_URL=https://frontend-test.banlv-ai.com
NEXTAUTH_URL_INTERNAL=http://localhost:3000 NEXTAUTH_URL_INTERNAL=http://localhost:3000
NEXT_PUBLIC_API_BASE_URL=https://api.cozsweet.com NEXT_PUBLIC_API_BASE_URL=https://testapi.banlv-ai.com
NEXT_PUBLIC_WS_BASE_URL=wss://api.cozsweet.com/ws NEXT_PUBLIC_WS_BASE_URL=wss://testapi.banlv-ai.com/ws
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000 NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
+26 -14
View File
@@ -8,7 +8,7 @@ import type { LoginStatus } from "@/data/dto/auth";
import { AppStorage } from "@/data/storage/app/app_storage"; import { AppStorage } from "@/data/storage/app/app_storage";
import { AuthStorage } from "@/data/storage/auth/auth_storage"; import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { UserStorage } from "@/data/storage/user/user_storage"; import { UserStorage } from "@/data/storage/user/user_storage";
import { useChatState } from "@/stores/chat/chat-context"; import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
import { AppEnvUtil, BrowserDetector, todayString, UrlLauncherUtil } from "@/utils"; import { AppEnvUtil, BrowserDetector, todayString, UrlLauncherUtil } from "@/utils";
import { ROUTE_BUILDERS, ROUTES } from "@/router/routes"; import { ROUTE_BUILDERS, ROUTES } from "@/router/routes";
@@ -35,6 +35,7 @@ function deriveIsGuest(loginStatus: LoginStatus): boolean {
export function ChatScreen() { export function ChatScreen() {
const state = useChatState(); const state = useChatState();
const chatDispatch = useChatDispatch();
const authState = useAuthState(); const authState = useAuthState();
const [showExternalBrowserDialog, setShowExternalBrowserDialog] = const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
useState(false); useState(false);
@@ -48,8 +49,16 @@ export function ChatScreen() {
state.paywallReason === "daily_limit"; state.paywallReason === "daily_limit";
const showPhotoPaywallBanner = const showPhotoPaywallBanner =
state.paywallTriggered && state.paywallReason === "photo_paywall"; state.paywallTriggered && state.paywallReason === "photo_paywall";
const showExhaustedBanner = const showPrivatePaywallBanner =
showDailyLimitBanner || showPhotoPaywallBanner; state.paywallTriggered && state.paywallReason === "private_paywall";
const showInlineUpgradeBanner =
showPhotoPaywallBanner || showPrivatePaywallBanner;
const inlineUpgradeTitle = showPrivatePaywallBanner
? "Unlock VIP to view private messages"
: "Unlock VIP to view Elio's photos";
const inlineUpgradeCta = showPrivatePaywallBanner
? "Activate VIP to view private messages"
: "Activate VIP to view photos";
const externalBrowserPromptShownRef = useRef(false); const externalBrowserPromptShownRef = useRef(false);
@@ -122,6 +131,10 @@ export function ChatScreen() {
UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.splash); UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.splash);
} }
function handleUnlockPrivateMessage(messageId: string): void {
chatDispatch({ type: "ChatUnlockPrivateMessage", messageId });
}
return ( return (
<MobileShell> <MobileShell>
<div className={styles.shell}> <div className={styles.shell}>
@@ -144,20 +157,19 @@ export function ChatScreen() {
messages={state.messages} messages={state.messages}
isReplyingAI={state.isReplyingAI} isReplyingAI={state.isReplyingAI}
isGuest={isGuest} isGuest={isGuest}
unlockingPrivateMessageId={state.unlockingPrivateMessageId}
onUnlockPrivateMessage={handleUnlockPrivateMessage}
/> />
{showExhaustedBanner ? ( {showInlineUpgradeBanner ? (
<ChatQuotaExhaustedBanner
title={inlineUpgradeTitle}
ctaLabel={inlineUpgradeCta}
/>
) : null}
{showDailyLimitBanner ? (
<ChatQuotaExhaustedBanner <ChatQuotaExhaustedBanner
title={
showPhotoPaywallBanner
? "Unlock VIP to view Elio's photos"
: undefined
}
ctaLabel={
showPhotoPaywallBanner
? "Activate VIP to view photos"
: undefined
}
/> />
) : ( ) : (
<ChatInputBar disabled={state.isReplyingAI} /> <ChatInputBar disabled={state.isReplyingAI} />
+26 -3
View File
@@ -28,9 +28,16 @@ export interface ChatAreaProps {
messages: readonly UiMessage[]; messages: readonly UiMessage[];
isReplyingAI: boolean; isReplyingAI: boolean;
isGuest: boolean; isGuest: boolean;
unlockingPrivateMessageId?: string | null;
onUnlockPrivateMessage?: (messageId: string) => void;
} }
export function ChatArea({ messages, isReplyingAI }: ChatAreaProps) { export function ChatArea({
messages,
isReplyingAI,
unlockingPrivateMessageId,
onUnlockPrivateMessage,
}: ChatAreaProps) {
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const prevLengthRef = useRef(messages.length); const prevLengthRef = useRef(messages.length);
@@ -49,7 +56,11 @@ export function ChatArea({ messages, isReplyingAI }: ChatAreaProps) {
<main ref={scrollRef} className={styles.area} aria-label="Chat messages"> <main ref={scrollRef} className={styles.area} aria-label="Chat messages">
<AiDisclosureBanner /> <AiDisclosureBanner />
{renderMessagesWithDateHeaders(messages)} {renderMessagesWithDateHeaders(
messages,
unlockingPrivateMessageId,
onUnlockPrivateMessage,
)}
{isReplyingAI && <LottieMessageBubble />} {isReplyingAI && <LottieMessageBubble />}
</main> </main>
@@ -57,7 +68,11 @@ export function ChatArea({ messages, isReplyingAI }: ChatAreaProps) {
} }
/** 渲染消息列表(按日期分组插入分隔条) */ /** 渲染消息列表(按日期分组插入分隔条) */
function renderMessagesWithDateHeaders(messages: readonly UiMessage[]) { function renderMessagesWithDateHeaders(
messages: readonly UiMessage[],
unlockingPrivateMessageId?: string | null,
onUnlockPrivateMessage?: (messageId: string) => void,
) {
const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = []; const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = [];
messages.forEach((m, i) => { messages.forEach((m, i) => {
@@ -73,9 +88,17 @@ function renderMessagesWithDateHeaders(messages: readonly UiMessage[]) {
) : ( ) : (
<MessageBubble <MessageBubble
key={item.key} key={item.key}
id={item.message.id}
content={item.message.content} content={item.message.content}
imageUrl={item.message.imageUrl} imageUrl={item.message.imageUrl}
isFromAI={item.message.isFromAI} isFromAI={item.message.isFromAI}
privateLocked={item.message.privateLocked}
privateHint={item.message.privateHint}
isUnlockingPrivate={
item.message.id != null &&
item.message.id === unlockingPrivateMessageId
}
onUnlockPrivateMessage={onUnlockPrivateMessage}
/> />
), ),
); );
+1
View File
@@ -22,5 +22,6 @@ export * from "./message-bubble";
export * from "./message-content"; export * from "./message-content";
export * from "./pwa-install-dialog"; export * from "./pwa-install-dialog";
export * from "./pwa-install-overlay"; export * from "./pwa-install-overlay";
export * from "./private-message-card";
export * from "./text-bubble"; export * from "./text-bubble";
export * from "./user-message-avatar"; export * from "./user-message-avatar";
+30 -2
View File
@@ -15,12 +15,26 @@ import { MessageContent } from "./message-content";
import styles from "./chat-area.module.css"; import styles from "./chat-area.module.css";
export interface MessageBubbleProps { export interface MessageBubbleProps {
id?: string;
content: string; content: string;
imageUrl?: string | null; imageUrl?: string | null;
isFromAI: boolean; isFromAI: boolean;
privateLocked?: boolean | null;
privateHint?: string | null;
isUnlockingPrivate?: boolean;
onUnlockPrivateMessage?: (messageId: string) => void;
} }
export function MessageBubble({ content, imageUrl, isFromAI }: MessageBubbleProps) { export function MessageBubble({
id,
content,
imageUrl,
isFromAI,
privateLocked,
privateHint,
isUnlockingPrivate,
onUnlockPrivateMessage,
}: MessageBubbleProps) {
const { avatarUrl } = useUserState(); const { avatarUrl } = useUserState();
if (isFromAI) { if (isFromAI) {
@@ -29,9 +43,14 @@ export function MessageBubble({ content, imageUrl, isFromAI }: MessageBubbleProp
<MessageAvatar isFromAI={true} /> <MessageAvatar isFromAI={true} />
<div style={{ width: 8 }} /> <div style={{ width: 8 }} />
<MessageContent <MessageContent
messageId={id}
content={content} content={content}
imageUrl={imageUrl} imageUrl={imageUrl}
isFromAI={true} isFromAI={true}
privateLocked={privateLocked}
privateHint={privateHint}
isUnlockingPrivate={isUnlockingPrivate}
onUnlockPrivateMessage={onUnlockPrivateMessage}
/> />
<div style={{ width: 43 }} /> {/* 占位:保持与头像宽度一致 */} <div style={{ width: 43 }} /> {/* 占位:保持与头像宽度一致 */}
</div> </div>
@@ -41,7 +60,16 @@ export function MessageBubble({ content, imageUrl, isFromAI }: MessageBubbleProp
return ( return (
<div className={styles.bubbleRowUser} aria-label="User message"> <div className={styles.bubbleRowUser} aria-label="User message">
<div style={{ width: 43 }} /> {/* 占位 */} <div style={{ width: 43 }} /> {/* 占位 */}
<MessageContent content={content} imageUrl={imageUrl} isFromAI={false} /> <MessageContent
messageId={id}
content={content}
imageUrl={imageUrl}
isFromAI={false}
privateLocked={privateLocked}
privateHint={privateHint}
isUnlockingPrivate={isUnlockingPrivate}
onUnlockPrivateMessage={onUnlockPrivateMessage}
/>
<div style={{ width: 8 }} /> <div style={{ width: 8 }} />
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} /> <MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
</div> </div>
+31 -1
View File
@@ -10,19 +10,35 @@
* - 两者都有:图片在上,文字在下 * - 两者都有:图片在上,文字在下
*/ */
import { ImageBubble } from "./image-bubble"; import { ImageBubble } from "./image-bubble";
import { PrivateMessageCard } from "./private-message-card";
import { TextBubble } from "./text-bubble"; import { TextBubble } from "./text-bubble";
export interface MessageContentProps { export interface MessageContentProps {
messageId?: string;
content: string; content: string;
imageUrl?: string | null; imageUrl?: string | null;
isFromAI: boolean; isFromAI: boolean;
privateLocked?: boolean | null;
privateHint?: string | null;
isUnlockingPrivate?: boolean;
onUnlockPrivateMessage?: (messageId: string) => void;
} }
const IMAGE_PLACEHOLDER = "[图片]"; const IMAGE_PLACEHOLDER = "[图片]";
export function MessageContent({ content, imageUrl, isFromAI }: MessageContentProps) { export function MessageContent({
messageId,
content,
imageUrl,
isFromAI,
privateLocked,
privateHint,
isUnlockingPrivate = false,
onUnlockPrivateMessage,
}: 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;
return ( return (
<div <div
@@ -37,8 +53,22 @@ export function MessageContent({ content, imageUrl, isFromAI }: MessageContentPr
gap: 8, gap: 8,
}} }}
> >
{isLockedPrivateMessage ? (
<PrivateMessageCard
hint={privateHint}
isUnlocking={isUnlockingPrivate}
onUnlock={
messageId
? () => onUnlockPrivateMessage?.(messageId)
: undefined
}
/>
) : (
<>
{hasImage && imageUrl && <ImageBubble imageUrl={imageUrl} />} {hasImage && imageUrl && <ImageBubble imageUrl={imageUrl} />}
{hasText && <TextBubble content={content} isFromAI={isFromAI} />} {hasText && <TextBubble content={content} isFromAI={isFromAI} />}
</>
)}
</div> </div>
); );
} }
@@ -0,0 +1,57 @@
.card {
max-width: min(280px, 100%);
padding: 14px;
border: 1px solid rgba(246, 87, 160, 0.2);
border-radius: 18px;
background:
linear-gradient(180deg, rgba(255, 244, 248, 0.95), rgba(255, 255, 255, 0.95)),
#ffffff;
box-shadow: 0 4px 12px rgba(246, 87, 160, 0.12);
color: #3c3b3b;
}
.iconWrap {
width: 42px;
height: 42px;
border-radius: 50%;
background: linear-gradient(135deg, #ff8fc7 0%, #f657a0 100%);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
}
.icon {
display: block;
}
.hint {
margin: 0;
color: #3c3b3b;
font-size: 14px;
line-height: 1.4;
}
.button {
width: 100%;
margin-top: 12px;
padding: 10px 12px;
border: 0;
border-radius: 999px;
background: linear-gradient(90deg, #ff67e0, #ff52a2);
color: #ffffff;
cursor: pointer;
font-size: 14px;
font-weight: 700;
}
.button:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.button:focus-visible {
outline: 2px solid #f657a0;
outline-offset: 3px;
}
@@ -0,0 +1,38 @@
"use client";
import { LockKeyhole } from "lucide-react";
import styles from "./private-message-card.module.css";
export interface PrivateMessageCardProps {
hint?: string | null;
isUnlocking?: boolean;
onUnlock?: () => void;
}
export function PrivateMessageCard({
hint,
isUnlocking = false,
onUnlock,
}: PrivateMessageCardProps) {
return (
<div className={styles.card} role="group" aria-label="Locked private message">
<div className={styles.iconWrap} aria-hidden="true">
<LockKeyhole className={styles.icon} size={22} />
</div>
<p className={styles.hint}>
{hint && hint.length > 0
? hint
: "Elio has a private message for you."}
</p>
<button
type="button"
className={styles.button}
disabled={isUnlocking || !onUnlock}
onClick={onUnlock}
>
{isUnlocking ? "Unlocking..." : "Unlock private message"}
</button>
</div>
);
}
+4
View File
@@ -8,6 +8,7 @@
import { z } from "zod"; import { z } from "zod";
export const UiMessageSchema = z.object({ export const UiMessageSchema = z.object({
id: z.string().optional(),
content: z.string(), content: z.string(),
isFromAI: z.boolean(), isFromAI: z.boolean(),
/** 日期分隔条使用的本地日期(YYYY-MM-DD */ /** 日期分隔条使用的本地日期(YYYY-MM-DD */
@@ -16,6 +17,9 @@ export const UiMessageSchema = z.object({
imageUrl: z.string().optional(), imageUrl: z.string().optional(),
/** 语音 URL */ /** 语音 URL */
voiceUrl: z.string().optional(), voiceUrl: z.string().optional(),
isPrivate: z.boolean().nullable().optional(),
privateLocked: z.boolean().nullable().optional(),
privateHint: z.string().nullable().optional(),
}); });
export type UiMessage = z.infer<typeof UiMessageSchema>; export type UiMessage = z.infer<typeof UiMessageSchema>;
+39
View File
@@ -68,6 +68,39 @@ export class ChatRepository implements IChatRepository {
); );
} }
/** 把本地缓存中的私密消息标记为已解锁。 */
async markPrivateMessageUnlockedInLocal(
messageId: string,
content: string,
): Promise<Result<void>> {
return Result.wrap(async () => {
const localResult = await this.getLocalMessages();
if (Result.isErr(localResult)) {
throw localResult.error;
}
let changed = false;
const updatedMessages = localResult.data.map((message) => {
if (message.id !== messageId) return message;
changed = true;
return ChatMessage.from({
...message.toJson(),
content,
isPrivate: message.isPrivate ?? true,
privateLocked: false,
privateHint: null,
});
});
if (!changed) return;
const saveResult = await this.saveMessagesToLocal(updatedMessages);
if (Result.isErr(saveResult)) {
throw saveResult.error;
}
});
}
// ============ 本地(Dexie)操作 ============ // ============ 本地(Dexie)操作 ============
/** 把一条消息写入本地存储。 */ /** 把一条消息写入本地存储。 */
@@ -125,6 +158,9 @@ export class ChatRepository implements IChatRepository {
content: message.content, content: message.content,
createdAt: message.createdAt, createdAt: message.createdAt,
imageUrl: message.imageUrl, imageUrl: message.imageUrl,
isPrivate: message.isPrivate,
privateLocked: message.privateLocked,
privateHint: message.privateHint,
sessionId: "", sessionId: "",
}); });
} }
@@ -137,6 +173,9 @@ export class ChatRepository implements IChatRepository {
content: local.content, content: local.content,
createdAt: local.createdAt, createdAt: local.createdAt,
imageUrl: local.imageUrl, imageUrl: local.imageUrl,
isPrivate: local.isPrivate,
privateLocked: local.privateLocked,
privateHint: local.privateHint,
}); });
} }
@@ -32,6 +32,12 @@ export interface IChatRepository {
/** 解锁私密消息。 */ /** 解锁私密消息。 */
unlockPrivateMessage(messageId: string): Promise<Result<UnlockPrivateResponse>>; unlockPrivateMessage(messageId: string): Promise<Result<UnlockPrivateResponse>>;
/** 把本地缓存中的私密消息标记为已解锁。 */
markPrivateMessageUnlockedInLocal(
messageId: string,
content: string,
): Promise<Result<void>>;
/** 把一条消息写入本地存储。 */ /** 把一条消息写入本地存储。 */
saveMessageToLocal(message: ChatMessage): Promise<Result<void>>; saveMessageToLocal(message: ChatMessage): Promise<Result<void>>;
@@ -13,10 +13,10 @@ export const UnlockPrivateReasonSchema = z.enum([
export const UnlockPrivateResponseSchema = z.object({ export const UnlockPrivateResponseSchema = z.object({
unlocked: z.boolean(), unlocked: z.boolean(),
content: z.string().nullable(), content: z.string().nullable(),
showUpgrade: z.boolean(), showUpgrade: z.boolean().default(false),
paywallTriggered: z.boolean(), paywallTriggered: z.boolean().default(false),
privateFreeLimit: z.number(), privateFreeLimit: z.number().default(0),
privateUsedToday: z.number(), privateUsedToday: z.number().default(0),
reason: UnlockPrivateReasonSchema, reason: UnlockPrivateReasonSchema,
}); });
+3
View File
@@ -22,6 +22,9 @@ export interface LocalMessageRow {
content: string; content: string;
createdAt: string; createdAt: string;
imageUrl?: string | null; imageUrl?: string | null;
isPrivate?: boolean | null;
privateLocked?: boolean | null;
privateHint?: string | null;
sessionId: string; sessionId: string;
} }
+12
View File
@@ -23,6 +23,9 @@ export const LocalMessageSchema = z.object({
content: z.string(), content: z.string(),
createdAt: z.string().default(""), createdAt: z.string().default(""),
imageUrl: z.string().nullable().default(null), imageUrl: z.string().nullable().default(null),
isPrivate: z.boolean().nullable().default(null),
privateLocked: z.boolean().nullable().default(null),
privateHint: z.string().nullable().default(null),
sessionId: z.string().default(""), sessionId: z.string().default(""),
}); });
@@ -35,6 +38,9 @@ export class LocalMessage {
declare readonly content: string; declare readonly content: string;
declare readonly createdAt: string; declare readonly createdAt: string;
declare readonly imageUrl: string | null; declare readonly imageUrl: string | null;
declare readonly isPrivate: boolean | null;
declare readonly privateLocked: boolean | null;
declare readonly privateHint: string | null;
declare readonly sessionId: string; declare readonly sessionId: string;
private constructor(input: LocalMessageInput) { private constructor(input: LocalMessageInput) {
@@ -63,6 +69,9 @@ export class LocalMessage {
content: this.content, content: this.content,
createdAt: this.createdAt, createdAt: this.createdAt,
imageUrl: this.imageUrl, imageUrl: this.imageUrl,
isPrivate: this.isPrivate,
privateLocked: this.privateLocked,
privateHint: this.privateHint,
sessionId: this.sessionId, sessionId: this.sessionId,
}; };
} }
@@ -75,6 +84,9 @@ export class LocalMessage {
content: row.content, content: row.content,
createdAt: row.createdAt, createdAt: row.createdAt,
imageUrl: row.imageUrl ?? null, imageUrl: row.imageUrl ?? null,
isPrivate: row.isPrivate ?? null,
privateLocked: row.privateLocked ?? null,
privateHint: row.privateHint ?? null,
sessionId: row.sessionId, sessionId: row.sessionId,
}); });
} }
+2
View File
@@ -24,6 +24,7 @@ interface ChatState {
paywallTriggered: boolean; paywallTriggered: boolean;
paywallReason: MachineContext["paywallReason"]; paywallReason: MachineContext["paywallReason"];
paywallDetail: MachineContext["paywallDetail"]; paywallDetail: MachineContext["paywallDetail"];
unlockingPrivateMessageId: MachineContext["unlockingPrivateMessageId"];
isLoadingMore: boolean; isLoadingMore: boolean;
hasMore: boolean; hasMore: boolean;
historyOffset: number; historyOffset: number;
@@ -49,6 +50,7 @@ export function ChatProvider({ children }: ChatProviderProps) {
paywallTriggered: state.context.paywallTriggered, paywallTriggered: state.context.paywallTriggered,
paywallReason: state.context.paywallReason, paywallReason: state.context.paywallReason,
paywallDetail: state.context.paywallDetail, paywallDetail: state.context.paywallDetail,
unlockingPrivateMessageId: state.context.unlockingPrivateMessageId,
isLoadingMore: state.context.isLoadingMore, isLoadingMore: state.context.isLoadingMore,
hasMore: state.context.hasMore, hasMore: state.context.hasMore,
historyOffset: state.context.historyOffset, historyOffset: state.context.historyOffset,
+1
View File
@@ -25,6 +25,7 @@ export type ChatEvent =
// 业务事件 // 业务事件
| { type: "ChatSendMessage"; content: string } | { type: "ChatSendMessage"; content: string }
| { type: "ChatSendImage"; imageBase64: string } | { type: "ChatSendImage"; imageBase64: string }
| { type: "ChatUnlockPrivateMessage"; messageId: string }
| { type: "ChatLoadMoreHistory" } | { type: "ChatLoadMoreHistory" }
| { type: "ChatImageReceived"; imageUrl: string } | { type: "ChatImageReceived"; imageUrl: string }
| { | {
+35
View File
@@ -105,6 +105,41 @@ export const loadMoreHistoryActor = fromPromise<
}; };
}); });
export const unlockPrivateMessageActor = fromPromise<
{
messageId: string;
response: import("@/data/dto/chat").UnlockPrivateResponse;
},
{ messageId: string }
>(async ({ input }) => {
const result = await chatRepo.unlockPrivateMessage(input.messageId);
if (Result.isErr(result)) {
log.error("[chat-machine] unlockPrivateMessageActor failed", {
messageId: input.messageId,
error: result.error,
});
throw result.error;
}
if (result.data.unlocked && result.data.content != null) {
const localResult = await chatRepo.markPrivateMessageUnlockedInLocal(
input.messageId,
result.data.content,
);
if (Result.isErr(localResult)) {
log.error("[chat-machine] unlockPrivateMessageActor local sync failed", {
messageId: input.messageId,
error: localResult.error,
});
}
}
return {
messageId: input.messageId,
response: result.data,
};
});
// ============================================================ // ============================================================
// WebSocket: long-lived callback // WebSocket: long-lived callback
// ============================================================ // ============================================================
+9
View File
@@ -49,17 +49,25 @@ export const chatRepo: IChatRepository = chatRepository;
*/ */
export function localMessagesToUi( export function localMessagesToUi(
records: ReadonlyArray<{ records: ReadonlyArray<{
id?: string;
content: string; content: string;
role: string; role: string;
createdAt: string; createdAt: string;
imageUrl?: string | null; imageUrl?: string | null;
isPrivate?: boolean | null;
privateLocked?: boolean | null;
privateHint?: string | null;
}>, }>,
): UiMessage[] { ): UiMessage[] {
return records.map((m) => ({ return records.map((m) => ({
...(m.id ? { id: m.id } : {}),
content: m.content, content: m.content,
isFromAI: m.role === "assistant", isFromAI: m.role === "assistant",
date: messageDateFromCreatedAt(m.createdAt), date: messageDateFromCreatedAt(m.createdAt),
...(m.imageUrl ? { imageUrl: m.imageUrl } : {}), ...(m.imageUrl ? { imageUrl: m.imageUrl } : {}),
...(m.isPrivate != null ? { isPrivate: m.isPrivate } : {}),
...(m.privateLocked != null ? { privateLocked: m.privateLocked } : {}),
...(m.privateHint != null ? { privateHint: m.privateHint } : {}),
})); }));
} }
@@ -76,6 +84,7 @@ function messageDateFromCreatedAt(createdAt: string): string {
*/ */
export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage { export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
return { return {
...(response.messageId ? { id: response.messageId } : {}),
content: response.reply, content: response.reply,
isFromAI: true, isFromAI: true,
date: todayString(new Date(response.timestamp)), date: todayString(new Date(response.timestamp)),
+124
View File
@@ -47,6 +47,7 @@ import {
sendMessageWsActor, sendMessageWsActor,
loadMoreHistoryActor, loadMoreHistoryActor,
chatWebSocketActor, chatWebSocketActor,
unlockPrivateMessageActor,
} from "./chat-machine.actors"; } from "./chat-machine.actors";
const log = new Logger("StoresChatChatMachine"); const log = new Logger("StoresChatChatMachine");
@@ -70,6 +71,7 @@ export const chatMachine = setup({
sendMessageWs: sendMessageWsActor, sendMessageWs: sendMessageWsActor,
loadMoreHistory: loadMoreHistoryActor, loadMoreHistory: loadMoreHistoryActor,
chatWebSocket: chatWebSocketActor, chatWebSocket: chatWebSocketActor,
unlockPrivateMessage: unlockPrivateMessageActor,
}, },
actions: { actions: {
startGuestSession: assign(() => ({ startGuestSession: assign(() => ({
@@ -276,6 +278,65 @@ export const chatMachine = setup({
}; };
}), }),
setUnlockingPrivateMessage: assign(({ event }) => {
if (event.type !== "ChatUnlockPrivateMessage") return {};
return {
unlockingPrivateMessageId: event.messageId,
paywallTriggered: false,
paywallReason: null,
paywallDetail: null,
};
}),
applyUnlockPrivateOutput: assign(({ context, event }) => {
if (!("output" in event)) return {};
const output = event.output as {
messageId: string;
response: import("@/data/dto/chat").UnlockPrivateResponse;
};
const { messageId, response } = output;
if (response.unlocked && response.content != null) {
return {
messages: context.messages.map((message) =>
message.id === messageId
? {
...message,
content: response.content ?? message.content,
privateLocked: false,
privateHint: null,
isPrivate: message.isPrivate ?? true,
}
: message,
),
unlockingPrivateMessageId: null,
paywallTriggered: false,
paywallReason: null,
paywallDetail: null,
};
}
if (response.showUpgrade) {
return {
unlockingPrivateMessageId: null,
paywallTriggered: true,
paywallReason: "private_paywall",
paywallDetail: {
usedToday: response.privateUsedToday,
limit: response.privateFreeLimit,
},
};
}
return {
unlockingPrivateMessageId: null,
};
}),
clearUnlockingPrivateMessage: assign({
unlockingPrivateMessageId: null,
}),
setWsConnected: assign({ wsConnected: true }), setWsConnected: assign({ wsConnected: true }),
clearWsConnected: assign({ wsConnected: false }), clearWsConnected: assign({ wsConnected: false }),
}, },
@@ -356,6 +417,10 @@ export const chatMachine = setup({
actions: "appendGuestUserImage", actions: "appendGuestUserImage",
target: "sending", target: "sending",
}, },
ChatUnlockPrivateMessage: {
actions: "setUnlockingPrivateMessage",
target: "unlockingPrivate",
},
// 删除 ChatLoadMoreHistory / WS handlers —— 游客无服务端 history,也不连接 WS // 删除 ChatLoadMoreHistory / WS handlers —— 游客无服务端 history,也不连接 WS
}, },
}, },
@@ -377,6 +442,23 @@ export const chatMachine = setup({
}, },
}, },
}, },
unlockingPrivate: {
invoke: {
src: "unlockPrivateMessage",
input: ({ event }) => ({
messageId:
event.type === "ChatUnlockPrivateMessage" ? event.messageId : "",
}),
onDone: {
target: "ready",
actions: "applyUnlockPrivateOutput",
},
onError: {
target: "ready",
actions: "clearUnlockingPrivateMessage",
},
},
},
}, },
}, },
@@ -437,6 +519,10 @@ export const chatMachine = setup({
ChatLoadMoreHistory: { ChatLoadMoreHistory: {
target: "loadingMore", target: "loadingMore",
}, },
ChatUnlockPrivateMessage: {
actions: "setUnlockingPrivateMessage",
target: "unlockingPrivate",
},
}, },
}, },
sendingViaHttp: { sendingViaHttp: {
@@ -476,6 +562,23 @@ export const chatMachine = setup({
}, },
}, },
}, },
unlockingPrivate: {
invoke: {
src: "unlockPrivateMessage",
input: ({ event }) => ({
messageId:
event.type === "ChatUnlockPrivateMessage" ? event.messageId : "",
}),
onDone: {
target: "ready",
actions: "applyUnlockPrivateOutput",
},
onError: {
target: "ready",
actions: "clearUnlockingPrivateMessage",
},
},
},
}, },
}, },
@@ -577,6 +680,10 @@ export const chatMachine = setup({
ChatLoadMoreHistory: { ChatLoadMoreHistory: {
target: "loadingMore", target: "loadingMore",
}, },
ChatUnlockPrivateMessage: {
actions: "setUnlockingPrivateMessage",
target: "unlockingPrivate",
},
// 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected // 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected
// 已上提到 vipUserSession.on,子级不再声明 // 已上提到 vipUserSession.on,子级不再声明
}, },
@@ -651,6 +758,23 @@ export const chatMachine = setup({
}, },
}, },
}, },
unlockingPrivate: {
invoke: {
src: "unlockPrivateMessage",
input: ({ event }) => ({
messageId:
event.type === "ChatUnlockPrivateMessage" ? event.messageId : "",
}),
onDone: {
target: "ready",
actions: "applyUnlockPrivateOutput",
},
onError: {
target: "ready",
actions: "clearUnlockingPrivateMessage",
},
},
},
}, },
}, },
}, },
+3 -1
View File
@@ -19,8 +19,9 @@ export interface ChatState {
*/ */
wsConnected: boolean; wsConnected: boolean;
paywallTriggered: boolean; paywallTriggered: boolean;
paywallReason: "daily_limit" | "photo_paywall" | null; paywallReason: "daily_limit" | "photo_paywall" | "private_paywall" | null;
paywallDetail: { usedToday: number; limit: number } | null; paywallDetail: { usedToday: number; limit: number } | null;
unlockingPrivateMessageId: string | null;
isLoadingMore: boolean; isLoadingMore: boolean;
hasMore: boolean; hasMore: boolean;
historyOffset: number; historyOffset: number;
@@ -38,6 +39,7 @@ export const initialState: ChatState = {
paywallTriggered: false, paywallTriggered: false,
paywallReason: null, paywallReason: null,
paywallDetail: null, paywallDetail: null,
unlockingPrivateMessageId: null,
isLoadingMore: false, isLoadingMore: false,
hasMore: true, hasMore: true,
historyOffset: 0, historyOffset: 0,