feat(chat): add private message unlock flow
This commit is contained in:
@@ -3,8 +3,8 @@ NEXT_PUBLIC_APP_ENV=test
|
||||
# NextAuth v4 —— OAuth callback **公**网 base URL(**必**须与**公**网域名**一**致)
|
||||
NEXTAUTH_URL=https://frontend-test.banlv-ai.com
|
||||
NEXTAUTH_URL_INTERNAL=http://localhost:3000
|
||||
NEXT_PUBLIC_API_BASE_URL=https://api.cozsweet.com
|
||||
NEXT_PUBLIC_WS_BASE_URL=wss://api.cozsweet.com/ws
|
||||
NEXT_PUBLIC_API_BASE_URL=https://testapi.banlv-ai.com
|
||||
NEXT_PUBLIC_WS_BASE_URL=wss://testapi.banlv-ai.com/ws
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R
|
||||
|
||||
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { LoginStatus } from "@/data/dto/auth";
|
||||
import { AppStorage } from "@/data/storage/app/app_storage";
|
||||
import { AuthStorage } from "@/data/storage/auth/auth_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 { ROUTE_BUILDERS, ROUTES } from "@/router/routes";
|
||||
|
||||
@@ -35,6 +35,7 @@ function deriveIsGuest(loginStatus: LoginStatus): boolean {
|
||||
|
||||
export function ChatScreen() {
|
||||
const state = useChatState();
|
||||
const chatDispatch = useChatDispatch();
|
||||
const authState = useAuthState();
|
||||
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
|
||||
useState(false);
|
||||
@@ -48,8 +49,16 @@ export function ChatScreen() {
|
||||
state.paywallReason === "daily_limit";
|
||||
const showPhotoPaywallBanner =
|
||||
state.paywallTriggered && state.paywallReason === "photo_paywall";
|
||||
const showExhaustedBanner =
|
||||
showDailyLimitBanner || showPhotoPaywallBanner;
|
||||
const showPrivatePaywallBanner =
|
||||
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);
|
||||
|
||||
@@ -122,6 +131,10 @@ export function ChatScreen() {
|
||||
UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.splash);
|
||||
}
|
||||
|
||||
function handleUnlockPrivateMessage(messageId: string): void {
|
||||
chatDispatch({ type: "ChatUnlockPrivateMessage", messageId });
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileShell>
|
||||
<div className={styles.shell}>
|
||||
@@ -144,20 +157,19 @@ export function ChatScreen() {
|
||||
messages={state.messages}
|
||||
isReplyingAI={state.isReplyingAI}
|
||||
isGuest={isGuest}
|
||||
unlockingPrivateMessageId={state.unlockingPrivateMessageId}
|
||||
onUnlockPrivateMessage={handleUnlockPrivateMessage}
|
||||
/>
|
||||
|
||||
{showExhaustedBanner ? (
|
||||
{showInlineUpgradeBanner ? (
|
||||
<ChatQuotaExhaustedBanner
|
||||
title={inlineUpgradeTitle}
|
||||
ctaLabel={inlineUpgradeCta}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showDailyLimitBanner ? (
|
||||
<ChatQuotaExhaustedBanner
|
||||
title={
|
||||
showPhotoPaywallBanner
|
||||
? "Unlock VIP to view Elio's photos"
|
||||
: undefined
|
||||
}
|
||||
ctaLabel={
|
||||
showPhotoPaywallBanner
|
||||
? "Activate VIP to view photos"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ChatInputBar disabled={state.isReplyingAI} />
|
||||
|
||||
@@ -28,9 +28,16 @@ export interface ChatAreaProps {
|
||||
messages: readonly UiMessage[];
|
||||
isReplyingAI: 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 prevLengthRef = useRef(messages.length);
|
||||
|
||||
@@ -49,7 +56,11 @@ export function ChatArea({ messages, isReplyingAI }: ChatAreaProps) {
|
||||
<main ref={scrollRef} className={styles.area} aria-label="Chat messages">
|
||||
<AiDisclosureBanner />
|
||||
|
||||
{renderMessagesWithDateHeaders(messages)}
|
||||
{renderMessagesWithDateHeaders(
|
||||
messages,
|
||||
unlockingPrivateMessageId,
|
||||
onUnlockPrivateMessage,
|
||||
)}
|
||||
|
||||
{isReplyingAI && <LottieMessageBubble />}
|
||||
</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 }> = [];
|
||||
|
||||
messages.forEach((m, i) => {
|
||||
@@ -73,9 +88,17 @@ function renderMessagesWithDateHeaders(messages: readonly UiMessage[]) {
|
||||
) : (
|
||||
<MessageBubble
|
||||
key={item.key}
|
||||
id={item.message.id}
|
||||
content={item.message.content}
|
||||
imageUrl={item.message.imageUrl}
|
||||
isFromAI={item.message.isFromAI}
|
||||
privateLocked={item.message.privateLocked}
|
||||
privateHint={item.message.privateHint}
|
||||
isUnlockingPrivate={
|
||||
item.message.id != null &&
|
||||
item.message.id === unlockingPrivateMessageId
|
||||
}
|
||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
@@ -22,5 +22,6 @@ export * from "./message-bubble";
|
||||
export * from "./message-content";
|
||||
export * from "./pwa-install-dialog";
|
||||
export * from "./pwa-install-overlay";
|
||||
export * from "./private-message-card";
|
||||
export * from "./text-bubble";
|
||||
export * from "./user-message-avatar";
|
||||
|
||||
@@ -15,12 +15,26 @@ import { MessageContent } from "./message-content";
|
||||
import styles from "./chat-area.module.css";
|
||||
|
||||
export interface MessageBubbleProps {
|
||||
id?: string;
|
||||
content: string;
|
||||
imageUrl?: string | null;
|
||||
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();
|
||||
|
||||
if (isFromAI) {
|
||||
@@ -29,9 +43,14 @@ export function MessageBubble({ content, imageUrl, isFromAI }: MessageBubbleProp
|
||||
<MessageAvatar isFromAI={true} />
|
||||
<div style={{ width: 8 }} />
|
||||
<MessageContent
|
||||
messageId={id}
|
||||
content={content}
|
||||
imageUrl={imageUrl}
|
||||
isFromAI={true}
|
||||
privateLocked={privateLocked}
|
||||
privateHint={privateHint}
|
||||
isUnlockingPrivate={isUnlockingPrivate}
|
||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||
/>
|
||||
<div style={{ width: 43 }} /> {/* 占位:保持与头像宽度一致 */}
|
||||
</div>
|
||||
@@ -41,7 +60,16 @@ export function MessageBubble({ content, imageUrl, isFromAI }: MessageBubbleProp
|
||||
return (
|
||||
<div className={styles.bubbleRowUser} aria-label="User message">
|
||||
<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 }} />
|
||||
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
|
||||
</div>
|
||||
|
||||
@@ -10,19 +10,35 @@
|
||||
* - 两者都有:图片在上,文字在下
|
||||
*/
|
||||
import { ImageBubble } from "./image-bubble";
|
||||
import { PrivateMessageCard } from "./private-message-card";
|
||||
import { TextBubble } from "./text-bubble";
|
||||
|
||||
export interface MessageContentProps {
|
||||
messageId?: string;
|
||||
content: string;
|
||||
imageUrl?: string | null;
|
||||
isFromAI: boolean;
|
||||
privateLocked?: boolean | null;
|
||||
privateHint?: string | null;
|
||||
isUnlockingPrivate?: boolean;
|
||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
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 hasText = content.length > 0 && content !== IMAGE_PLACEHOLDER;
|
||||
const isLockedPrivateMessage = privateLocked === true;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -37,8 +53,22 @@ export function MessageContent({ content, imageUrl, isFromAI }: MessageContentPr
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{isLockedPrivateMessage ? (
|
||||
<PrivateMessageCard
|
||||
hint={privateHint}
|
||||
isUnlocking={isUnlockingPrivate}
|
||||
onUnlock={
|
||||
messageId
|
||||
? () => onUnlockPrivateMessage?.(messageId)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{hasImage && imageUrl && <ImageBubble imageUrl={imageUrl} />}
|
||||
{hasText && <TextBubble content={content} isFromAI={isFromAI} />}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const UiMessageSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
content: z.string(),
|
||||
isFromAI: z.boolean(),
|
||||
/** 日期分隔条使用的本地日期(YYYY-MM-DD) */
|
||||
@@ -16,6 +17,9 @@ export const UiMessageSchema = z.object({
|
||||
imageUrl: z.string().optional(),
|
||||
/** 语音 URL */
|
||||
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>;
|
||||
|
||||
@@ -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)操作 ============
|
||||
|
||||
/** 把一条消息写入本地存储。 */
|
||||
@@ -125,6 +158,9 @@ export class ChatRepository implements IChatRepository {
|
||||
content: message.content,
|
||||
createdAt: message.createdAt,
|
||||
imageUrl: message.imageUrl,
|
||||
isPrivate: message.isPrivate,
|
||||
privateLocked: message.privateLocked,
|
||||
privateHint: message.privateHint,
|
||||
sessionId: "",
|
||||
});
|
||||
}
|
||||
@@ -137,6 +173,9 @@ export class ChatRepository implements IChatRepository {
|
||||
content: local.content,
|
||||
createdAt: local.createdAt,
|
||||
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>>;
|
||||
|
||||
/** 把本地缓存中的私密消息标记为已解锁。 */
|
||||
markPrivateMessageUnlockedInLocal(
|
||||
messageId: string,
|
||||
content: string,
|
||||
): Promise<Result<void>>;
|
||||
|
||||
/** 把一条消息写入本地存储。 */
|
||||
saveMessageToLocal(message: ChatMessage): Promise<Result<void>>;
|
||||
|
||||
|
||||
@@ -13,10 +13,10 @@ export const UnlockPrivateReasonSchema = z.enum([
|
||||
export const UnlockPrivateResponseSchema = z.object({
|
||||
unlocked: z.boolean(),
|
||||
content: z.string().nullable(),
|
||||
showUpgrade: z.boolean(),
|
||||
paywallTriggered: z.boolean(),
|
||||
privateFreeLimit: z.number(),
|
||||
privateUsedToday: z.number(),
|
||||
showUpgrade: z.boolean().default(false),
|
||||
paywallTriggered: z.boolean().default(false),
|
||||
privateFreeLimit: z.number().default(0),
|
||||
privateUsedToday: z.number().default(0),
|
||||
reason: UnlockPrivateReasonSchema,
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ export interface LocalMessageRow {
|
||||
content: string;
|
||||
createdAt: string;
|
||||
imageUrl?: string | null;
|
||||
isPrivate?: boolean | null;
|
||||
privateLocked?: boolean | null;
|
||||
privateHint?: string | null;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ export const LocalMessageSchema = z.object({
|
||||
content: z.string(),
|
||||
createdAt: z.string().default(""),
|
||||
imageUrl: z.string().nullable().default(null),
|
||||
isPrivate: z.boolean().nullable().default(null),
|
||||
privateLocked: z.boolean().nullable().default(null),
|
||||
privateHint: z.string().nullable().default(null),
|
||||
sessionId: z.string().default(""),
|
||||
});
|
||||
|
||||
@@ -35,6 +38,9 @@ export class LocalMessage {
|
||||
declare readonly content: string;
|
||||
declare readonly createdAt: string;
|
||||
declare readonly imageUrl: string | null;
|
||||
declare readonly isPrivate: boolean | null;
|
||||
declare readonly privateLocked: boolean | null;
|
||||
declare readonly privateHint: string | null;
|
||||
declare readonly sessionId: string;
|
||||
|
||||
private constructor(input: LocalMessageInput) {
|
||||
@@ -63,6 +69,9 @@ export class LocalMessage {
|
||||
content: this.content,
|
||||
createdAt: this.createdAt,
|
||||
imageUrl: this.imageUrl,
|
||||
isPrivate: this.isPrivate,
|
||||
privateLocked: this.privateLocked,
|
||||
privateHint: this.privateHint,
|
||||
sessionId: this.sessionId,
|
||||
};
|
||||
}
|
||||
@@ -75,6 +84,9 @@ export class LocalMessage {
|
||||
content: row.content,
|
||||
createdAt: row.createdAt,
|
||||
imageUrl: row.imageUrl ?? null,
|
||||
isPrivate: row.isPrivate ?? null,
|
||||
privateLocked: row.privateLocked ?? null,
|
||||
privateHint: row.privateHint ?? null,
|
||||
sessionId: row.sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ interface ChatState {
|
||||
paywallTriggered: boolean;
|
||||
paywallReason: MachineContext["paywallReason"];
|
||||
paywallDetail: MachineContext["paywallDetail"];
|
||||
unlockingPrivateMessageId: MachineContext["unlockingPrivateMessageId"];
|
||||
isLoadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
historyOffset: number;
|
||||
@@ -49,6 +50,7 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
||||
paywallTriggered: state.context.paywallTriggered,
|
||||
paywallReason: state.context.paywallReason,
|
||||
paywallDetail: state.context.paywallDetail,
|
||||
unlockingPrivateMessageId: state.context.unlockingPrivateMessageId,
|
||||
isLoadingMore: state.context.isLoadingMore,
|
||||
hasMore: state.context.hasMore,
|
||||
historyOffset: state.context.historyOffset,
|
||||
|
||||
@@ -25,6 +25,7 @@ export type ChatEvent =
|
||||
// 业务事件
|
||||
| { type: "ChatSendMessage"; content: string }
|
||||
| { type: "ChatSendImage"; imageBase64: string }
|
||||
| { type: "ChatUnlockPrivateMessage"; messageId: string }
|
||||
| { type: "ChatLoadMoreHistory" }
|
||||
| { type: "ChatImageReceived"; imageUrl: string }
|
||||
| {
|
||||
|
||||
@@ -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
|
||||
// ============================================================
|
||||
|
||||
@@ -49,17 +49,25 @@ export const chatRepo: IChatRepository = chatRepository;
|
||||
*/
|
||||
export function localMessagesToUi(
|
||||
records: ReadonlyArray<{
|
||||
id?: string;
|
||||
content: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
imageUrl?: string | null;
|
||||
isPrivate?: boolean | null;
|
||||
privateLocked?: boolean | null;
|
||||
privateHint?: string | null;
|
||||
}>,
|
||||
): UiMessage[] {
|
||||
return records.map((m) => ({
|
||||
...(m.id ? { id: m.id } : {}),
|
||||
content: m.content,
|
||||
isFromAI: m.role === "assistant",
|
||||
date: messageDateFromCreatedAt(m.createdAt),
|
||||
...(m.imageUrl ? { imageUrl: m.imageUrl } : {}),
|
||||
...(m.isPrivate != null ? { isPrivate: m.isPrivate } : {}),
|
||||
...(m.privateLocked != null ? { privateLocked: m.privateLocked } : {}),
|
||||
...(m.privateHint != null ? { privateHint: m.privateHint } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -76,6 +84,7 @@ function messageDateFromCreatedAt(createdAt: string): string {
|
||||
*/
|
||||
export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
|
||||
return {
|
||||
...(response.messageId ? { id: response.messageId } : {}),
|
||||
content: response.reply,
|
||||
isFromAI: true,
|
||||
date: todayString(new Date(response.timestamp)),
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
sendMessageWsActor,
|
||||
loadMoreHistoryActor,
|
||||
chatWebSocketActor,
|
||||
unlockPrivateMessageActor,
|
||||
} from "./chat-machine.actors";
|
||||
|
||||
const log = new Logger("StoresChatChatMachine");
|
||||
@@ -70,6 +71,7 @@ export const chatMachine = setup({
|
||||
sendMessageWs: sendMessageWsActor,
|
||||
loadMoreHistory: loadMoreHistoryActor,
|
||||
chatWebSocket: chatWebSocketActor,
|
||||
unlockPrivateMessage: unlockPrivateMessageActor,
|
||||
},
|
||||
actions: {
|
||||
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 }),
|
||||
clearWsConnected: assign({ wsConnected: false }),
|
||||
},
|
||||
@@ -356,6 +417,10 @@ export const chatMachine = setup({
|
||||
actions: "appendGuestUserImage",
|
||||
target: "sending",
|
||||
},
|
||||
ChatUnlockPrivateMessage: {
|
||||
actions: "setUnlockingPrivateMessage",
|
||||
target: "unlockingPrivate",
|
||||
},
|
||||
// 删除 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: {
|
||||
target: "loadingMore",
|
||||
},
|
||||
ChatUnlockPrivateMessage: {
|
||||
actions: "setUnlockingPrivateMessage",
|
||||
target: "unlockingPrivate",
|
||||
},
|
||||
},
|
||||
},
|
||||
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: {
|
||||
target: "loadingMore",
|
||||
},
|
||||
ChatUnlockPrivateMessage: {
|
||||
actions: "setUnlockingPrivateMessage",
|
||||
target: "unlockingPrivate",
|
||||
},
|
||||
// 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected
|
||||
// 已上提到 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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -19,8 +19,9 @@ export interface ChatState {
|
||||
*/
|
||||
wsConnected: boolean;
|
||||
paywallTriggered: boolean;
|
||||
paywallReason: "daily_limit" | "photo_paywall" | null;
|
||||
paywallReason: "daily_limit" | "photo_paywall" | "private_paywall" | null;
|
||||
paywallDetail: { usedToday: number; limit: number } | null;
|
||||
unlockingPrivateMessageId: string | null;
|
||||
isLoadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
historyOffset: number;
|
||||
@@ -38,6 +39,7 @@ export const initialState: ChatState = {
|
||||
paywallTriggered: false,
|
||||
paywallReason: null,
|
||||
paywallDetail: null,
|
||||
unlockingPrivateMessageId: null,
|
||||
isLoadingMore: false,
|
||||
hasMore: true,
|
||||
historyOffset: 0,
|
||||
|
||||
Reference in New Issue
Block a user