diff --git a/env-example/.env.local.example b/env-example/.env.local.example index 869256ad..9a7f6e48 100644 --- a/env-example/.env.local.example +++ b/env-example/.env.local.example @@ -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 diff --git a/next.config.ts b/next.config.ts index cf915a5f..c4ede2aa 100644 --- a/next.config.ts +++ b/next.config.ts @@ -11,15 +11,6 @@ import { withSerwist } from "@serwist/turbopack"; * - 脚本侧:scripts/deploy/_deploy_lib.sh 同步 .next/ + public/ * - 服务器启动:`cd && next start -H 0.0.0.0 -p 3000` * 或用 PM2:`pm2 start "next start" --name cozsweet` - * - * PWA 集成:`@serwist/turbopack` 在 build 阶段用 esbuild 编译 `src/app/sw.ts` 源文件。 - * - Turbopack 原生支持 —— 不再需要 `--webpack` flag - * - SW 通过 Serwist Route Handler(src/app/serwist/[[...slug]]/route.ts)在 `/serwist/sw.js` 服务 - * - 客户端注册走 ``(替代旧的 ) - * - * 历史: - * - 之前用 @ducanh2912/next-pwa(webpack-only),被迫用 `--webpack` 退 Turbopack - * - 切到 @serwist/turbopack 后拿回 Turbopack 的 dev/build 性能 */ const nextConfig: NextConfig = { experimental: { @@ -27,10 +18,6 @@ const nextConfig: NextConfig = { staticGenerationRetryCount: 0, }, images: { - // Facebook OAuth 登录后,`picture.type(large)` 返回 - // platform-lookaside.fbsbx.com/platform/profilepic/?asid=...&hash=... - // 该 URL 是**下载链接**而非可缓存图片,next/image 优化器需要显式 allowlist。 - // graph.facebook.com 兜底:Graph 偶尔返回直链头像 / OG 图。 remotePatterns: [ { protocol: "https", @@ -40,8 +27,13 @@ const nextConfig: NextConfig = { protocol: "https", hostname: "graph.facebook.com", }, + { + protocol: "https", + hostname: "**.supabase.co", + pathname: "/storage/v1/object/public/**", + }, ], }, }; -export default withSerwist(nextConfig); \ No newline at end of file +export default withSerwist(nextConfig); diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index ce88b7ab..703a31bd 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -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); @@ -42,19 +43,22 @@ export function ChatScreen() { // isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段) const isGuest = deriveIsGuest(authState.loginStatus); - // 游客配额耗尽 → 显示 Unlock CTA banner(替代 ChatInputBar) - // 三重条件:游客 + 配额加载完 + 用户被配额 guard 拦截过至少一次 - // - quotaLoaded 避免一进 /chat 就跳 banner(loadQuota 还在 in-flight) - // - quotaExceededTrigger 而非 guestTotalQuota / guestRemainingQuota —— - // trigger 由 chat 机器的 guard 在 ChatSendMessage / ChatSendImage 被拦截时 +1, - // 是 "用户被配额拒绝过" 的权威信号(避免依赖 quota 数值本身的边界判断) - const showGuestQuotaBanner = - isGuest && state.quotaLoaded && state.quotaExceededTrigger > 0; + // 消息数量限制由后端统一返回 blocked/daily_limit,前端不再处理本地额度。 const showDailyLimitBanner = - !isGuest && state.paywallTriggered && state.paywallReason === "daily_limit"; - const showExhaustedBanner = showGuestQuotaBanner || showDailyLimitBanner; + const showPhotoPaywallBanner = + state.paywallTriggered && state.paywallReason === "photo_paywall"; + 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); @@ -127,6 +131,10 @@ export function ChatScreen() { UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.splash); } + function handleUnlockPrivateMessage(messageId: string): void { + chatDispatch({ type: "ChatUnlockPrivateMessage", messageId }); + } + return (
@@ -149,10 +157,20 @@ export function ChatScreen() { messages={state.messages} isReplyingAI={state.isReplyingAI} isGuest={isGuest} + unlockingPrivateMessageId={state.unlockingPrivateMessageId} + onUnlockPrivateMessage={handleUnlockPrivateMessage} /> - {showExhaustedBanner ? ( - + {showInlineUpgradeBanner ? ( + + ) : null} + + {showDailyLimitBanner ? ( + ) : ( )} diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index 9d8a9f51..417a3671 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -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(null); const prevLengthRef = useRef(messages.length); @@ -49,7 +56,11 @@ export function ChatArea({ messages, isReplyingAI }: ChatAreaProps) {
- {renderMessagesWithDateHeaders(messages)} + {renderMessagesWithDateHeaders( + messages, + unlockingPrivateMessageId, + onUnlockPrivateMessage, + )} {isReplyingAI && }
@@ -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[]) { ) : ( ), ); diff --git a/src/app/chat/components/chat-input-bar.tsx b/src/app/chat/components/chat-input-bar.tsx index 2a4f7085..119bb30a 100644 --- a/src/app/chat/components/chat-input-bar.tsx +++ b/src/app/chat/components/chat-input-bar.tsx @@ -1,25 +1,4 @@ "use client"; -/** - * ChatInputBar 输入栏 - * - * 原始 Dart: lib/ui/chat/widgets/chat_input_bar.dart(121 行) - * - * 状态: - * - input:受控文本 - * - isFocused:是否聚焦(控制外层 pink-bg / box-shadow / padding-top) - * - hasContent:是否有 trim 内容(决定发送按钮是否启用) - * - * 容器层次(与 Dart 一致): - * - 外层 `.bar`:focused 时 `padding-top: md` + 粉色背景 + 阴影 - * - 内层 `.row`:白底 + `border-radius: 32` + focused 时 accent 边框 - * - Row:[ChatInputTextField (Expanded), ChatSendButton] - * - * 行为: - * - send 后清空 input + 保持 textarea 焦点(与 Dart `_focusNode.requestFocus()` 一致) - * - * 注意:Dart 源里的 `ImageUploadButton` 已被注释(`// const ImageUploadButton()`), - * `ChatMoreButton` / `FunctionButtonBar` 不存在于 Dart 源;本组件仅保留与 Dart 一致的部分。 - */ import { useRef, useState } from "react"; import { useChatDispatch } from "@/stores/chat/chat-context"; diff --git a/src/app/chat/components/chat-input-text-field.tsx b/src/app/chat/components/chat-input-text-field.tsx index 9525a19b..7700b48f 100644 --- a/src/app/chat/components/chat-input-text-field.tsx +++ b/src/app/chat/components/chat-input-text-field.tsx @@ -44,7 +44,7 @@ export const ChatInputTextField = forwardRef< onChange, onSubmit, onFocusChange, - placeholder = "|Say anything that’s on your mind ^U^…", + placeholder = "Say anything that’s on your mind ^U^…", disabled = false, autoFocus = false, }, diff --git a/src/app/chat/components/chat-quota-exhausted-banner.tsx b/src/app/chat/components/chat-quota-exhausted-banner.tsx index 14e380a2..5196d93c 100644 --- a/src/app/chat/components/chat-quota-exhausted-banner.tsx +++ b/src/app/chat/components/chat-quota-exhausted-banner.tsx @@ -3,9 +3,7 @@ * ChatQuotaExhaustedBanner 游客配额耗尽横幅 * * 何时显示: - * - isGuest === true(仅游客业务事实"非游客无配额上限") - * - quotaLoaded === true(避免一进 /chat 就跳 banner) - * - guestTotalQuota <= 0(用 total 而非 remaining —— 配额刚减为 0 不立即跳) + * - 后端返回 blocked=true && blockReason="daily_limit" * * 视觉规格(与设计稿对齐): * - 粉→品红渐变背景(与 splash 渐变一致) @@ -27,6 +25,8 @@ import { ROUTES } from "@/router/routes"; import styles from "./chat-quota-exhausted-banner.module.css"; export interface ChatQuotaExhaustedBannerProps { + title?: string; + ctaLabel?: string; /** * 自定义点击回调(不传则默认 router.push(ROUTES.subscription)) * - 测试时可传 mock @@ -36,9 +36,12 @@ export interface ChatQuotaExhaustedBannerProps { } export function ChatQuotaExhaustedBanner({ + title = "The limit for free chat times\nhas been reached today", + ctaLabel = "Unlock your membership to continue", onUnlock, }: ChatQuotaExhaustedBannerProps) { const router = useRouter(); + const titleLines = title.split("\n"); const handleClick = () => { if (onUnlock) { @@ -56,17 +59,20 @@ export function ChatQuotaExhaustedBanner({ data-testid="chat-quota-exhausted-banner" >

- The limit for free chat times -
- has been reached today + {titleLines.map((line, index) => ( + + {line} + {index < titleLines.length - 1 ?
: null} +
+ ))}

); diff --git a/src/app/chat/components/index.ts b/src/app/chat/components/index.ts index c522a0b5..56e09e75 100644 --- a/src/app/chat/components/index.ts +++ b/src/app/chat/components/index.ts @@ -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"; diff --git a/src/app/chat/components/message-bubble.tsx b/src/app/chat/components/message-bubble.tsx index 77297ce1..3c6c10e7 100644 --- a/src/app/chat/components/message-bubble.tsx +++ b/src/app/chat/components/message-bubble.tsx @@ -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
{/* 占位:保持与头像宽度一致 */}
@@ -41,7 +60,16 @@ export function MessageBubble({ content, imageUrl, isFromAI }: MessageBubbleProp return (
{/* 占位 */} - +
diff --git a/src/app/chat/components/message-content.tsx b/src/app/chat/components/message-content.tsx index e55671b6..76064fb8 100644 --- a/src/app/chat/components/message-content.tsx +++ b/src/app/chat/components/message-content.tsx @@ -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 (
- {hasImage && imageUrl && } - {hasText && } + {isLockedPrivateMessage ? ( + onUnlockPrivateMessage?.(messageId) + : undefined + } + /> + ) : ( + <> + {hasImage && imageUrl && } + {hasText && } + + )}
); } diff --git a/src/app/chat/components/private-message-card.module.css b/src/app/chat/components/private-message-card.module.css new file mode 100644 index 00000000..66821132 --- /dev/null +++ b/src/app/chat/components/private-message-card.module.css @@ -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; +} diff --git a/src/app/chat/components/private-message-card.tsx b/src/app/chat/components/private-message-card.tsx new file mode 100644 index 00000000..7c7deda1 --- /dev/null +++ b/src/app/chat/components/private-message-card.tsx @@ -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 ( +
+ +

+ {hint && hint.length > 0 + ? hint + : "Elio has a private message for you."} +

+ +
+ ); +} diff --git a/src/core/net/chat-websocket.ts b/src/core/net/chat-websocket.ts index 74b31482..30a591b0 100644 --- a/src/core/net/chat-websocket.ts +++ b/src/core/net/chat-websocket.ts @@ -29,6 +29,13 @@ export interface IntimacyPayload { relationshipStage?: string; } +export interface PaywallStatusPayload { + paywallTriggered: boolean; + showUpgrade: boolean; + imageType: string | null; + imageUrl: string | null; +} + export class ChatWebSocket { private ws: WebSocket | null = null; private reconnectTimer: ReturnType | null = null; @@ -38,6 +45,8 @@ export class ChatWebSocket { onTyping: ((isTyping: boolean) => void) | null = null; onSentence: ((payload: SentencePayload) => void) | null = null; onVoiceReady: ((index: number, audioUrl: string) => void) | null = null; + onImage: ((imageUrl: string) => void) | null = null; + onPaywallStatus: ((payload: PaywallStatusPayload) => void) | null = null; onIntimacyUpdate: ((payload: IntimacyPayload) => void) | null = null; onMoodUpdate: ((mood: string) => void) | null = null; onError: ((errorMessage: string) => void) | null = null; @@ -104,6 +113,12 @@ export class ChatWebSocket { relationshipStage?: string; mood?: string; error?: string; + data?: { + imageUrl?: string | null; + paywallTriggered?: boolean; + showUpgrade?: boolean; + imageType?: string | null; + }; }; try { payload = JSON.parse(data); @@ -128,6 +143,19 @@ export class ChatWebSocket { case "voice_ready": this.onVoiceReady?.(payload.index ?? 0, payload.audioUrl ?? ""); break; + case "image": { + const imageUrl = payload.data?.imageUrl; + if (imageUrl) this.onImage?.(imageUrl); + break; + } + case "paywall_status": + this.onPaywallStatus?.({ + paywallTriggered: payload.data?.paywallTriggered ?? false, + showUpgrade: payload.data?.showUpgrade ?? false, + imageType: payload.data?.imageType ?? null, + imageUrl: payload.data?.imageUrl ?? null, + }); + break; case "intimacy_update": this.onIntimacyUpdate?.({ intimacyChange: payload.intimacyChange, diff --git a/src/data/dto/chat/chat_message.ts b/src/data/dto/chat/chat_message.ts index 085844e5..9285b3eb 100644 --- a/src/data/dto/chat/chat_message.ts +++ b/src/data/dto/chat/chat_message.ts @@ -12,6 +12,7 @@ export class ChatMessage { declare readonly content: string; declare readonly id: string; declare readonly createdAt: string; + declare readonly imageUrl: string | null; declare readonly isPrivate: boolean | null; declare readonly privateLocked: boolean | null; declare readonly privateHint: string | null; diff --git a/src/data/dto/chat/guest_chat_quota.ts b/src/data/dto/chat/guest_chat_quota.ts deleted file mode 100644 index 820b6caf..00000000 --- a/src/data/dto/chat/guest_chat_quota.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * 游客每日聊天配额数据 DTO - */ -import { - GuestChatQuotaSchema, - type GuestChatQuotaInput, - type GuestChatQuotaData, -} from "@/data/schemas/chat/guest_chat_quota"; -import { AppEnvUtil } from "@/utils"; - -export class GuestChatQuota { - // 静态常量 - static readonly maxQuotaPerDay = 30; - static readonly maxQuotaPerDayTest = 4; - - static readonly defaultTotalQuota = 50; - static readonly defaultTotalQuotaTest = 5; - static readonly quotaExhausted = 0; - - declare readonly remaining: number; - declare readonly date: string; - - private constructor(input: GuestChatQuotaInput) { - const data = GuestChatQuotaSchema.parse(input); - Object.assign(this, data); - Object.freeze(this); - } - - static from(input: GuestChatQuotaInput): GuestChatQuota { - return new GuestChatQuota(input); - } - - static fromJson(json: unknown): GuestChatQuota { - return GuestChatQuota.from(json as GuestChatQuotaInput); - } - - /** - * 配额耗尽状态值 - */ - static get exhausted(): number { - return GuestChatQuota.quotaExhausted; - } - - /** - * 获取当前环境的每日最大配额 - */ - static get threshold(): number { - return AppEnvUtil.isProduction() - ? GuestChatQuota.maxQuotaPerDay - : GuestChatQuota.maxQuotaPerDayTest; - } - - /** - * 获取当前环境的总配额默认值 - */ - static get totalQuotaDefault(): number { - return AppEnvUtil.isProduction() - ? GuestChatQuota.defaultTotalQuota - : GuestChatQuota.defaultTotalQuotaTest; - } - - /** - * 创建初始配额实例 - */ - static initial(todayString: string): GuestChatQuota { - return new GuestChatQuota({ - remaining: GuestChatQuota.threshold, - date: todayString, - }); - } - - /** - * 是否需要重置(跨天) - */ - needsReset(todayString: string): boolean { - return this.date !== todayString; - } - - toJson(): GuestChatQuotaData { - return GuestChatQuotaSchema.parse(this); - } -} diff --git a/src/data/dto/chat/index.ts b/src/data/dto/chat/index.ts index 2d11b76e..6c3678dd 100644 --- a/src/data/dto/chat/index.ts +++ b/src/data/dto/chat/index.ts @@ -8,7 +8,6 @@ export * from "./chat_message"; export * from "./chat_send_response"; export * from "./chat_sync_data"; export * from "./chat_sync_request"; -export * from "./guest_chat_quota"; export * from "./image_upload_response"; export * from "./send_message_request"; export * from "./stt_data"; diff --git a/src/data/dto/chat/ui_message.ts b/src/data/dto/chat/ui_message.ts index fa154322..a0eac429 100644 --- a/src/data/dto/chat/ui_message.ts +++ b/src/data/dto/chat/ui_message.ts @@ -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; diff --git a/src/data/repositories/chat_repository.ts b/src/data/repositories/chat_repository.ts index a7146450..76fe0f5e 100644 --- a/src/data/repositories/chat_repository.ts +++ b/src/data/repositories/chat_repository.ts @@ -68,6 +68,39 @@ export class ChatRepository implements IChatRepository { ); } + /** 把本地缓存中的私密消息标记为已解锁。 */ + async markPrivateMessageUnlockedInLocal( + messageId: string, + content: string, + ): Promise> { + 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)操作 ============ /** 把一条消息写入本地存储。 */ @@ -124,6 +157,10 @@ export class ChatRepository implements IChatRepository { role: message.role, content: message.content, createdAt: message.createdAt, + imageUrl: message.imageUrl, + isPrivate: message.isPrivate, + privateLocked: message.privateLocked, + privateHint: message.privateHint, sessionId: "", }); } @@ -135,6 +172,10 @@ export class ChatRepository implements IChatRepository { role: local.role, content: local.content, createdAt: local.createdAt, + imageUrl: local.imageUrl, + isPrivate: local.isPrivate, + privateLocked: local.privateLocked, + privateHint: local.privateHint, }); } diff --git a/src/data/repositories/interfaces/ichat_repository.ts b/src/data/repositories/interfaces/ichat_repository.ts index 92b5dd43..f19ba0ec 100644 --- a/src/data/repositories/interfaces/ichat_repository.ts +++ b/src/data/repositories/interfaces/ichat_repository.ts @@ -32,6 +32,12 @@ export interface IChatRepository { /** 解锁私密消息。 */ unlockPrivateMessage(messageId: string): Promise>; + /** 把本地缓存中的私密消息标记为已解锁。 */ + markPrivateMessageUnlockedInLocal( + messageId: string, + content: string, + ): Promise>; + /** 把一条消息写入本地存储。 */ saveMessageToLocal(message: ChatMessage): Promise>; diff --git a/src/data/schemas/chat/chat_message.ts b/src/data/schemas/chat/chat_message.ts index 24d64353..030b97d6 100644 --- a/src/data/schemas/chat/chat_message.ts +++ b/src/data/schemas/chat/chat_message.ts @@ -11,13 +11,16 @@ export const ChatMessageSchema = z id: z.string().default(""), createdAt: z.string().default(""), created_at: z.string().optional(), + imageUrl: z.string().nullable().default(null), + image_url: z.string().nullable().optional(), isPrivate: z.boolean().nullable().default(null), privateLocked: z.boolean().nullable().default(null), privateHint: z.string().nullable().default(null), }) - .transform(({ created_at, ...data }) => ({ + .transform(({ created_at, image_url, ...data }) => ({ ...data, createdAt: data.createdAt || created_at || "", + imageUrl: data.imageUrl || image_url || null, })); export type ChatMessageInput = z.input; diff --git a/src/data/schemas/chat/guest_chat_quota.ts b/src/data/schemas/chat/guest_chat_quota.ts deleted file mode 100644 index 0d6c4450..00000000 --- a/src/data/schemas/chat/guest_chat_quota.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * 游客每日聊天配额数据 - * 原始 Dart: GuestChatQuota (lib/data/models/chat/guest_chat_quota.dart) - */ -import { z } from "zod"; - -export const GuestChatQuotaSchema = z.object({ - remaining: z.number(), - date: z.string(), -}); - -export type GuestChatQuotaInput = z.input; -export type GuestChatQuotaData = z.output; diff --git a/src/data/schemas/chat/index.ts b/src/data/schemas/chat/index.ts index 90049d31..a7d86ba3 100644 --- a/src/data/schemas/chat/index.ts +++ b/src/data/schemas/chat/index.ts @@ -7,7 +7,6 @@ export * from "./chat_message"; export * from "./chat_send_response"; export * from "./chat_sync_data"; export * from "./chat_sync_request"; -export * from "./guest_chat_quota"; export * from "./image_upload_response"; export * from "./send_message_request"; export * from "./stt_data"; diff --git a/src/data/schemas/chat/unlock_private_response.ts b/src/data/schemas/chat/unlock_private_response.ts index 378d7936..e9f8e63b 100644 --- a/src/data/schemas/chat/unlock_private_response.ts +++ b/src/data/schemas/chat/unlock_private_response.ts @@ -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, }); diff --git a/src/data/services/index.ts b/src/data/services/index.ts index 36f36e18..f085a552 100644 --- a/src/data/services/index.ts +++ b/src/data/services/index.ts @@ -35,7 +35,6 @@ export * from "../dto/chat/chat_message"; export * from "../dto/chat/chat_send_response"; export * from "../dto/chat/chat_sync_data"; export * from "../dto/chat/chat_sync_request"; -export * from "../dto/chat/guest_chat_quota"; export * from "../dto/chat/image_upload_response"; export * from "../dto/chat/send_message_request"; export * from "../dto/chat/stt_data"; @@ -69,7 +68,6 @@ export * from "../schemas/chat/chat_message"; export * from "../schemas/chat/chat_send_response"; export * from "../schemas/chat/chat_sync_data"; export * from "../schemas/chat/chat_sync_request"; -export * from "../schemas/chat/guest_chat_quota"; export * from "../schemas/chat/image_upload_response"; export * from "../schemas/chat/send_message_request"; export * from "../schemas/chat/stt_data"; diff --git a/src/data/storage/chat/__tests__/chat_storage.test.ts b/src/data/storage/chat/__tests__/chat_storage.test.ts deleted file mode 100644 index b3207491..00000000 --- a/src/data/storage/chat/__tests__/chat_storage.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { createStorage } from "unstorage"; -import memoryDriver from "unstorage/drivers/memory"; - -import { GuestChatQuota } from "@/data/dto/chat/guest_chat_quota"; -import { StorageKeys } from "@/data/storage/storage_keys"; -import { todayString, SpAsyncUtil } from "@/utils"; - -import { ChatStorage } from "../chat_storage"; - -describe("ChatStorage guest quota", () => { - beforeEach(() => { - SpAsyncUtil.setStorage(createStorage({ driver: memoryDriver() })); - ChatStorage._resetForTests(); - }); - - it("keeps an existing daily quota for the current day", async () => { - const storage = ChatStorage.getInstance(); - await storage.setGuestDailyChatQuota(2, todayString()); - - const result = await storage.getGuestDailyChatQuota(); - - expect(result.success).toBe(true); - expect(result.success ? result.data : null).toEqual({ - remaining: 2, - date: todayString(), - }); - }); - - it("resets daily quota only when the stored date is not today", async () => { - const storage = ChatStorage.getInstance(); - await storage.setGuestDailyChatQuota(1, "2000-01-01"); - - const result = await storage.getGuestDailyChatQuota(); - - expect(result.success).toBe(true); - expect(result.success ? result.data : null).toEqual({ - remaining: GuestChatQuota.threshold, - date: todayString(), - }); - }); - - it("does not overwrite invalid daily quota data with the default value", async () => { - await SpAsyncUtil.setString(StorageKeys.guestChatQuota, "{not-json"); - - const result = await ChatStorage.getInstance().getGuestDailyChatQuota(); - const stored = await SpAsyncUtil.getString(StorageKeys.guestChatQuota); - - expect(result.success).toBe(false); - expect(stored.success ? stored.data : null).toBe("{not-json"); - }); - - it("does not overwrite invalid total quota data with the default value", async () => { - await SpAsyncUtil.setString(StorageKeys.guestTotalQuotaRemaining, "{not-json"); - - const result = await ChatStorage.getInstance().getGuestTotalQuota(); - const stored = await SpAsyncUtil.getString(StorageKeys.guestTotalQuotaRemaining); - - expect(result.success).toBe(false); - expect(stored.success ? stored.data : null).toBe("{not-json"); - }); -}); diff --git a/src/data/storage/chat/chat_storage.ts b/src/data/storage/chat/chat_storage.ts index 2e0f7aa5..892ab934 100644 --- a/src/data/storage/chat/chat_storage.ts +++ b/src/data/storage/chat/chat_storage.ts @@ -1,17 +1,6 @@ "use client"; -import { - GuestChatQuotaSchema, - type GuestChatQuotaData, -} from "@/data/schemas/chat/guest_chat_quota"; -import { GuestChatQuota as GuestChatQuotaDto } from "@/data/dto/chat/guest_chat_quota"; -import { z } from "zod"; - -import { type Result as ResultT, Result, todayString, SpAsyncUtil } from "@/utils"; -import { StorageKeys } from "../storage_keys"; -import type { IChatStorage } from "./ichat_storage"; - -export class ChatStorage implements IChatStorage { +export class ChatStorage { private static _instance: ChatStorage | null = null; static getInstance(): ChatStorage { @@ -24,65 +13,6 @@ export class ChatStorage implements IChatStorage { ChatStorage._instance = null; } - // ---- guest daily chat quota ---- - - async getGuestDailyChatQuota(): Promise> { - const existing = await SpAsyncUtil.getJson( - StorageKeys.guestChatQuota, - GuestChatQuotaSchema, - ); - if (Result.isErr(existing)) return existing; - - const today = todayString(); - if (existing.data != null) { - const quota = GuestChatQuotaDto.from(existing.data); - if (!quota.needsReset(today)) return existing; - } - - const initResult = await this.setGuestDailyChatQuota( - GuestChatQuotaDto.threshold, - today, - ); - if (Result.isErr(initResult)) return existing; - - return { - success: true, - data: { remaining: GuestChatQuotaDto.threshold, date: today }, - }; - } - - setGuestDailyChatQuota(remaining: number, date: string): Promise> { - const value: GuestChatQuotaData = { remaining, date }; - return SpAsyncUtil.setJson(StorageKeys.guestChatQuota, value, GuestChatQuotaSchema); - } - - clearGuestDailyChatQuota(): Promise> { - return SpAsyncUtil.remove(StorageKeys.guestChatQuota); - } - - // ---- guest total quota ---- - - async getGuestTotalQuota(): Promise> { - const existing = await SpAsyncUtil.getJson( - StorageKeys.guestTotalQuotaRemaining, - z.number(), - ); - if (Result.isErr(existing)) return existing; - if (existing.data != null) return existing; - - const initResult = await this.setGuestTotalQuota( - GuestChatQuotaDto.totalQuotaDefault, - ); - if (Result.isErr(initResult)) return existing; - - return { success: true, data: GuestChatQuotaDto.totalQuotaDefault }; - } - - setGuestTotalQuota(remaining: number): Promise> { - return SpAsyncUtil.setJson(StorageKeys.guestTotalQuotaRemaining, remaining, z.number()); - } - - clearGuestTotalQuota(): Promise> { - return SpAsyncUtil.remove(StorageKeys.guestTotalQuotaRemaining); - } + // 本地游客消息数量额度逻辑已停用: + // 游客 / 非游客的消息数量限制统一由后端接口返回 blocked/daily_limit。 } diff --git a/src/data/storage/chat/ichat_storage.ts b/src/data/storage/chat/ichat_storage.ts deleted file mode 100644 index 4d743595..00000000 --- a/src/data/storage/chat/ichat_storage.ts +++ /dev/null @@ -1,28 +0,0 @@ -"use client"; - -/** - * IChatStorage 接口 - * - * 对齐 Dart 端 `IChatStorage`(lib/data/services/storage/chat/ichat_storage.dart): - * - 游客每日聊天配额 + 游客总配额 两个独立字段的 CRUD - * - * 注:`GuestChatQuota` 模型见 `src/data/dto/chat/guest_chat_quota.ts`, - * 数据形态 `{ remaining: number, date: string }`。 - * 跨天判断逻辑(`needsReset`)保留在 `GuestChatQuota` 类上。 - */ - -import type { Result } from "@/utils"; -import type { GuestChatQuotaData } from "@/data/schemas/chat/guest_chat_quota"; - -export interface IChatStorage { - getGuestDailyChatQuota(): Promise>; - setGuestDailyChatQuota( - remaining: number, - date: string, - ): Promise>; - clearGuestDailyChatQuota(): Promise>; - - getGuestTotalQuota(): Promise>; - setGuestTotalQuota(remaining: number): Promise>; - clearGuestTotalQuota(): Promise>; -} diff --git a/src/data/storage/chat/index.ts b/src/data/storage/chat/index.ts index f278bc90..b1510796 100644 --- a/src/data/storage/chat/index.ts +++ b/src/data/storage/chat/index.ts @@ -3,7 +3,6 @@ */ export * from "./chat_storage"; -export * from "./ichat_storage"; export * from "./local_chat_db"; export * from "./local_chat_storage"; export * from "./local_message"; diff --git a/src/data/storage/chat/local_chat_db.ts b/src/data/storage/chat/local_chat_db.ts index 53edcdea..f4a245af 100644 --- a/src/data/storage/chat/local_chat_db.ts +++ b/src/data/storage/chat/local_chat_db.ts @@ -21,6 +21,10 @@ export interface LocalMessageRow { role: string; content: string; createdAt: string; + imageUrl?: string | null; + isPrivate?: boolean | null; + privateLocked?: boolean | null; + privateHint?: string | null; sessionId: string; } diff --git a/src/data/storage/chat/local_message.ts b/src/data/storage/chat/local_message.ts index 72f1e5d7..de18ea4a 100644 --- a/src/data/storage/chat/local_message.ts +++ b/src/data/storage/chat/local_message.ts @@ -22,6 +22,10 @@ export const LocalMessageSchema = z.object({ role: z.string(), 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(""), }); @@ -33,6 +37,10 @@ export class LocalMessage { declare readonly role: string; declare readonly content: string; declare readonly createdAt: string; + declare readonly imageUrl: string | null; + declare readonly isPrivate: boolean | null; + declare readonly privateLocked: boolean | null; + declare readonly privateHint: string | null; declare readonly sessionId: string; private constructor(input: LocalMessageInput) { @@ -60,6 +68,10 @@ export class LocalMessage { role: this.role, content: this.content, createdAt: this.createdAt, + imageUrl: this.imageUrl, + isPrivate: this.isPrivate, + privateLocked: this.privateLocked, + privateHint: this.privateHint, sessionId: this.sessionId, }; } @@ -71,6 +83,10 @@ export class LocalMessage { role: row.role, 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, }); } diff --git a/src/data/storage/index.ts b/src/data/storage/index.ts index 5047502f..8e1b3a70 100644 --- a/src/data/storage/index.ts +++ b/src/data/storage/index.ts @@ -14,7 +14,6 @@ export * from "./app/app_storage"; export * from "./auth/auth_storage"; export * from "./auth/iauth_storage"; export * from "./chat/chat_storage"; -export * from "./chat/ichat_storage"; export * from "./chat/local_chat_db"; export * from "./chat/local_chat_storage"; export * from "./chat/local_message"; diff --git a/src/data/storage/storage_keys.ts b/src/data/storage/storage_keys.ts index d0c83f65..372b23de 100644 --- a/src/data/storage/storage_keys.ts +++ b/src/data/storage/storage_keys.ts @@ -21,8 +21,6 @@ export const StorageKeys = { // chat chatHistory: "chat_history", - guestChatQuota: "guest_chat_quota", - guestTotalQuotaRemaining: "guest_total_quota_remaining", // pwa / app info lastPwaDialogShown: "last_pwa_dialog_shown", diff --git a/src/stores/chat/chat-context.tsx b/src/stores/chat/chat-context.tsx index 3a52bfb8..a7c4ac75 100644 --- a/src/stores/chat/chat-context.tsx +++ b/src/stores/chat/chat-context.tsx @@ -21,19 +21,13 @@ import type { ChatEvent, ChatState as MachineContext } from "./chat-machine"; interface ChatState { messages: MachineContext["messages"]; isReplyingAI: boolean; - /** 游客剩余配额(次/天)—— 业务展示用,鉴权判断由 chat-screen 派生 loginStatus */ - guestRemainingQuota: number; - /** 游客总配额 */ - guestTotalQuota: number; - quotaExceededTrigger: number; paywallTriggered: boolean; paywallReason: MachineContext["paywallReason"]; paywallDetail: MachineContext["paywallDetail"]; + unlockingPrivateMessageId: MachineContext["unlockingPrivateMessageId"]; isLoadingMore: boolean; hasMore: boolean; historyOffset: number; - /** 游客配额加载完成标志(仅 guestSession 期间) —— UI 可用此显示 loading */ - quotaLoaded: boolean; /** history 初始加载完成标志(local → network → save 跑完) —— UI 可用此显示 loading */ historyLoaded: boolean; } @@ -53,16 +47,13 @@ export function ChatProvider({ children }: ChatProviderProps) { () => ({ messages: state.context.messages, isReplyingAI: state.context.isReplyingAI, - guestRemainingQuota: state.context.guestRemainingQuota, - guestTotalQuota: state.context.guestTotalQuota, - quotaExceededTrigger: state.context.quotaExceededTrigger, 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, - quotaLoaded: state.context.quotaLoaded, historyLoaded: state.context.historyLoaded, }), [state], diff --git a/src/stores/chat/chat-events.ts b/src/stores/chat/chat-events.ts index 066bb270..ec877663 100644 --- a/src/stores/chat/chat-events.ts +++ b/src/stores/chat/chat-events.ts @@ -25,7 +25,16 @@ export type ChatEvent = // 业务事件 | { type: "ChatSendMessage"; content: string } | { type: "ChatSendImage"; imageBase64: string } + | { type: "ChatUnlockPrivateMessage"; messageId: string } | { type: "ChatLoadMoreHistory" } + | { type: "ChatImageReceived"; imageUrl: string } + | { + type: "ChatPaywallStatusReceived"; + paywallTriggered: boolean; + showUpgrade: boolean; + imageType: string | null; + imageUrl: string | null; + } // WebSocket / AI 推句(由 chat 机器内部 actor 派回 —— "机器自驱动") | { type: "ChatAISentenceReceived"; @@ -35,5 +44,4 @@ export type ChatEvent = done: boolean; } | { type: "ChatWebSocketError"; errorMessage: string } - | { type: "ChatWebSocketConnected"; userId: string } - | { type: "ChatQuotaExceeded" }; + | { type: "ChatWebSocketConnected"; userId: string }; diff --git a/src/stores/chat/chat-machine.actors.ts b/src/stores/chat/chat-machine.actors.ts index a70e119f..2d9a20d8 100644 --- a/src/stores/chat/chat-machine.actors.ts +++ b/src/stores/chat/chat-machine.actors.ts @@ -13,7 +13,6 @@ import { chatRepo, localMessagesToUi, readAndSyncHistory, - readGuestQuota, sendResponseToUiMessage, } from "./chat-machine.helpers"; import type { ChatEvent } from "./chat-events"; @@ -42,20 +41,7 @@ export function getActiveChatWebSocket(): ChatWebSocket | null { return activeChatWebSocket; } -// ============================================================ -// Init 任务 1:拉游客配额(fromPromise,纯 local) -// ============================================================ -/** - * 拉游客日配 + 总配(仅 guestSession 期间 invoke) - * - 失败返 `{ remaining: 0, total: 0 }`(不抛,让 UI 还是能进 ready) - * - onDone 在 state machine 里赋 guestRemainingQuota + guestTotalQuota + 设 quotaLoaded: true - */ -export const loadQuotaActor = fromPromise<{ - remaining: number; - total: number; -}>(async () => { - return readGuestQuota(); -}); +// 本地游客消息额度 actor 已停用:消息数量限制统一交由后端 blocked/daily_limit 响应处理。 // ============================================================ // Init 任务 2:拉 history 3 步流(fromPromise,返回最终结果) @@ -119,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 // ============================================================ @@ -151,6 +172,18 @@ export const chatWebSocketActor = fromCallback( done: p.done, }); }; + ws.onImage = (imageUrl) => { + sendBack({ type: "ChatImageReceived", imageUrl }); + }; + ws.onPaywallStatus = (p) => { + sendBack({ + type: "ChatPaywallStatusReceived", + paywallTriggered: p.paywallTriggered, + showUpgrade: p.showUpgrade, + imageType: p.imageType, + imageUrl: p.imageUrl, + }); + }; ws.onError = (msg) => { log.error("[chat-machine] WS error", { errorMessage: msg }); sendBack({ type: "ChatWebSocketError", errorMessage: msg }); diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts index 9b1565c5..1f31b054 100644 --- a/src/stores/chat/chat-machine.helpers.ts +++ b/src/stores/chat/chat-machine.helpers.ts @@ -5,10 +5,7 @@ * - 翻页大小常量 * - 仓库注入(接口类型 → 单例) * - DTO ↔ UiMessage 映射(纯函数 —— 可单测) - * - Result → number 映射(纯函数 —— 可单测) - * - 2 个独立 init 任务的数据加载: - * - `readGuestQuota()` —— 游客日配 + 总配(仅游客有意义,纯 ChatStorage) - * - `readAndSyncHistory()` —— local → network → save network to local 3 步 + * - `readAndSyncHistory()` —— local → network → save network to local 3 步 * * 设计目标: * - helpers 无 XState 依赖(可独立 import / 测试) @@ -23,7 +20,6 @@ import type { UiMessage } from "@/data/dto/chat"; import { chatRepository } from "@/data/repositories/chat_repository"; import type { IChatRepository } from "@/data/repositories/interfaces"; -import { ChatStorage } from "@/data/storage/chat/chat_storage"; import { ChatSendResponse } from "@/data/dto/chat/chat_send_response"; import { todayString, Result, Logger } from "@/utils"; @@ -53,15 +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 } : {}), })); } @@ -78,9 +84,11 @@ 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)), + ...(response.imageUrl ? { imageUrl: response.imageUrl } : {}), }; } @@ -120,6 +128,16 @@ export function applyHttpSendOutput( }; } + if (response.showUpgrade) { + return { + messages: [...context.messages, reply], + isReplyingAI: false, + paywallTriggered: true, + paywallReason: "photo_paywall", + paywallDetail: null, + }; + } + return { messages: [...context.messages, reply], isReplyingAI: false, @@ -129,44 +147,8 @@ export function applyHttpSendOutput( }; } -// ============================================================ -// Result → number 映射 -// ============================================================ -/** 日配额 Result → number(纯函数) */ -export function mapQuotaResult( - r: Result<{ remaining: number } | null>, - fallback: number, -): number { - if (r.success && r.data != null) return r.data.remaining; - return fallback; -} - -/** 总配额 Result → number(纯函数) */ -export function mapTotalQuotaResult( - r: Result, - fallback: number, -): number { - if (r.success && r.data != null) return r.data; - return fallback; -} - -export async function readGuestQuota(): Promise<{ - remaining: number; - total: number; -}> { - const chatStorage = ChatStorage.getInstance(); - const [dailyResult, totalResult] = await Promise.all([ - chatStorage.getGuestDailyChatQuota(), - chatStorage.getGuestTotalQuota(), - ]); - return { - remaining: mapQuotaResult( - dailyResult as Result<{ remaining: number } | null>, - 0, - ), - total: mapTotalQuotaResult(totalResult as Result, 0), - }; -} +// 本地游客消息额度读取 / 映射逻辑已停用: +// 消息数量限制统一由后端接口返回 blocked/daily_limit,前端不再读取 ChatStorage quota。 // ============================================================ // Init 任务 2:history 3 步流(local → network → save)—— `loadHistoryActor` 调 diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index 3768a476..bbdc67c2 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -15,41 +15,39 @@ * - `nonVipUserSession`(parent):非 VIP 用户会话 —— 不 invoke WS * - `vipUserSession`(parent):VIP 用户会话 —— invoke WS(持久) * - * init 双任务(核心重构): - * - guestSession.initializing:loadQuota + loadHistory 并行(`invoke: [...]` 数组) + * init 任务: + * - guestSession.initializing:loadHistory * - nonVipUserSession.initializing:loadHistory * - vipUserSession.initializing:loadHistory + parent-level chatWebSocket 并行 * - 每个任务都是独立 actor,不互相等待(除非 `always` barrier) * * 消息发送路径(业务事实): - * - guestSession:走 HTTP,并先检查本地游客配额 + * - guestSession:走 HTTP,消息数量限制以后端 `blocked` 响应为准 * - nonVipUserSession:走 HTTP,让后端返回 daily_limit blocked * - vipUserSession + WS 已连:走 WS,不受每日免费次数限制 * - `ChatWebSocketConnected` 事件 → `wsConnected = true` * - `vipUserSession.exit` action → `wsConnected = false`(WS actor cleanup 时也会跑 exit) * * 配额: - * - 游客:保留本地配额 guard,命中后不发送消息 - * - 注册非 VIP:以后端 `blocked=true && blockReason="daily_limit"` 为准 - * - `appendUserMessage` 减 `context.wsConnected ? q : Math.max(0, q-1)` —— WS 已连时不递减 + * - 前端不再处理本地消息额度;游客 / 注册用户均以后端响应为准。 + * - 后端返回 `blocked=true && blockReason="daily_limit"` 时展示会员引导。 * */ import { setup, assign } from "xstate"; -import { ChatStorage } from "@/data/storage/chat/chat_storage"; import { todayString, Logger } from "@/utils"; import { ChatState, initialState } from "./chat-state"; import type { ChatEvent } from "./chat-events"; import { applyHttpSendOutput } from "./chat-machine.helpers"; import { - loadQuotaActor, loadHistoryActor, sendMessageHttpActor, sendMessageWsActor, loadMoreHistoryActor, chatWebSocketActor, + unlockPrivateMessageActor, } from "./chat-machine.actors"; const log = new Logger("StoresChatChatMachine"); @@ -68,12 +66,12 @@ export const chatMachine = setup({ events: {} as ChatEvent, }, actors: { - loadQuota: loadQuotaActor, loadHistory: loadHistoryActor, sendMessageHttp: sendMessageHttpActor, sendMessageWs: sendMessageWsActor, loadMoreHistory: loadMoreHistoryActor, chatWebSocket: chatWebSocketActor, + unlockPrivateMessage: unlockPrivateMessageActor, }, actions: { startGuestSession: assign(() => ({ @@ -91,25 +89,11 @@ export const chatMachine = setup({ appendGuestUserMessage: assign(({ context, event }) => { if (event.type !== "ChatSendMessage") return {}; - // 游客消息发送成功前先乐观扣减本地日配额 + 总配额。 - const newRemaining = Math.max(0, context.guestRemainingQuota - 1); - const newTotal = Math.max(0, context.guestTotalQuota - 1); - - // 持久化两个额度( fire-and-forget,不 await —— assign 是 sync) - // daily quota 需要 today 字符串(让 ChatStorage 跨天判断 reset) const today = todayString(); - ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today); - - ChatStorage.getInstance().setGuestTotalQuota(newTotal); - log.debug("[chat-machine] appendGuestUserMessage", { contentLength: event.content.length, contentPreview: event.content.slice(0, 50), - quotaMode: "guest http (decrement)", - oldRemaining: context.guestRemainingQuota, - newRemaining, - oldTotal: context.guestTotalQuota, - newTotal, + quotaMode: "server controlled", isReplyingAI: true, }); @@ -123,8 +107,6 @@ export const chatMachine = setup({ }, ], isReplyingAI: true, - guestRemainingQuota: newRemaining, - guestTotalQuota: newTotal, paywallTriggered: false, paywallReason: null, paywallDetail: null, @@ -160,22 +142,11 @@ export const chatMachine = setup({ appendGuestUserImage: assign(({ context, event }) => { if (event.type !== "ChatSendImage") return {}; - // 同 appendGuestUserMessage:游客两个额度同步减 - const newRemaining = Math.max(0, context.guestRemainingQuota - 1); - const newTotal = Math.max(0, context.guestTotalQuota - 1); - - const today = todayString(); - void ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today); - void ChatStorage.getInstance().setGuestTotalQuota(newTotal); - + const today = todayString(); log.debug("[chat-machine] appendGuestUserImage", { oldMessagesCount: context.messages.length, - quotaMode: "guest http (decrement)", - oldRemaining: context.guestRemainingQuota, - newRemaining, - oldTotal: context.guestTotalQuota, - newTotal, + quotaMode: "server controlled", }); return { messages: [ @@ -188,8 +159,6 @@ export const chatMachine = setup({ }, ], isReplyingAI: true, - guestRemainingQuota: newRemaining, - guestTotalQuota: newTotal, paywallTriggered: false, paywallReason: null, paywallDetail: null, @@ -261,9 +230,112 @@ export const chatMachine = setup({ return { messages, isReplyingAI: false }; }), - incrementQuotaExceeded: assign(({ context }) => ({ - quotaExceededTrigger: context.quotaExceededTrigger + 1, - })), + appendAIImage: assign(({ context, event }) => { + if (event.type !== "ChatImageReceived") return {}; + const messages = [...context.messages]; + const last = messages[messages.length - 1]; + + if (last?.isFromAI && !last.imageUrl) { + messages[messages.length - 1] = { + ...last, + imageUrl: event.imageUrl, + }; + } else { + messages.push({ + content: "", + isFromAI: true, + date: todayString(), + imageUrl: event.imageUrl, + }); + } + + log.debug("[chat-machine] appendAIImage", { + messagesCount: messages.length, + hasMergedIntoLastMessage: Boolean(last?.isFromAI && !last.imageUrl), + }); + + return { + messages, + paywallTriggered: false, + paywallReason: null, + paywallDetail: null, + }; + }), + + applyPaywallStatus: assign(({ event }) => { + if (event.type !== "ChatPaywallStatusReceived") return {}; + if (!event.showUpgrade) { + return { + paywallTriggered: false, + paywallReason: null, + paywallDetail: null, + }; + } + return { + paywallTriggered: true, + paywallReason: "photo_paywall", + paywallDetail: null, + }; + }), + + 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 }), @@ -308,94 +380,48 @@ export const chatMachine = setup({ initial: "initializing", states: { initializing: { - // "always" barrier:两个任务都完成才进 ready + // 本地游客额度逻辑已停用:只等待历史加载完成。 always: [ { target: "ready", - guard: ({ context }) => context.quotaLoaded && context.historyLoaded, + guard: ({ context }) => context.historyLoaded, }, ], - invoke: [ - // 任务 1:加载配额(fromPromise,返结果 + onDone 设 flag) - { - id: "loadQuota", - src: "loadQuota", - onDone: { - actions: assign(({ event }) => ({ - guestRemainingQuota: event.output.remaining, - guestTotalQuota: event.output.total, - quotaLoaded: true, - })), - }, - onError: { - // 失败也标 loaded,不卡 init(游客可能 quota 服务不可用,让 UI 进 ready) - actions: assign({ - quotaLoaded: true, - }), - }, + invoke: { + id: "loadHistory", + src: "loadHistory", + onDone: { + actions: assign(({ event }) => ({ + messages: event.output.messages, + hasMore: event.output.hasMore, + historyOffset: event.output.newOffset, + historyLoaded: true, + })), }, - // 任务 2:拉 history(fromPromise,返回最终 network messages) - { - id: "loadHistory", - src: "loadHistory", - onDone: { - actions: assign(({ event }) => ({ - messages: event.output.messages, - hasMore: event.output.hasMore, - historyOffset: event.output.newOffset, - historyLoaded: true, - })), - }, - onError: { - // 失败也标 loaded,不卡 init(让 UI 还是能进 ready,屏幕可能空) - actions: assign({ - historyLoaded: true, - }), - }, + onError: { + // 失败也标 loaded,不卡 init(让 UI 还是能进 ready,屏幕可能空) + actions: assign({ + historyLoaded: true, + }), }, - ], + }, }, ready: { on: { - // 配额检查 + 发送消息(3 条 guard,按顺序匹配) - // 1. 总配额(guestTotalQuota)不足 → 触发 quota exceeded dialog,不发送 - // 2. 每日配额(guestRemainingQuota)不足 → 同上 - // 3. 两个配额都 OK + 内容非空 → 发送 + 减配额 - ChatSendMessage: [ - // 1. 总配额 不足(guestTotalQuota <= 0) → 触发 quota exceeded dialog - { - guard: ({ context }) => context.guestTotalQuota <= 0, - actions: "incrementQuotaExceeded", - }, - // 2. 每日配额 不足(guestRemainingQuota <= 0) → 触发 quota exceeded dialog - { - guard: ({ context }) => context.guestRemainingQuota <= 0, - actions: "incrementQuotaExceeded", - }, - // 3. 两个配额都 OK + 内容非空 → 发送(只用 appendUserMessage 自己的日志) - { - actions: "appendGuestUserMessage", - guard: ({ event }) => event.content.trim().length > 0, - target: "sending", - }, - ], - // 配额检查 + 发送图片(同样 3 条 guard,不检查 "内容空") - ChatSendImage: [ - { - guard: ({ context }) => context.guestTotalQuota <= 0, - actions: "incrementQuotaExceeded", - }, - { - guard: ({ context }) => context.guestRemainingQuota <= 0, - actions: "incrementQuotaExceeded", - }, - { - actions: "appendGuestUserImage", - target: "sending", - }, - ], + ChatSendMessage: { + actions: "appendGuestUserMessage", + guard: ({ event }) => event.content.trim().length > 0, + target: "sending", + }, + ChatSendImage: { + actions: "appendGuestUserImage", + target: "sending", + }, + ChatUnlockPrivateMessage: { + actions: "setUnlockingPrivateMessage", + target: "unlockingPrivate", + }, // 删除 ChatLoadMoreHistory / WS handlers —— 游客无服务端 history,也不连接 WS - ChatQuotaExceeded: { actions: "incrementQuotaExceeded" }, }, }, sending: { @@ -416,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", + }, + }, + }, }, }, @@ -476,7 +519,10 @@ export const chatMachine = setup({ ChatLoadMoreHistory: { target: "loadingMore", }, - ChatQuotaExceeded: { actions: "incrementQuotaExceeded" }, + ChatUnlockPrivateMessage: { + actions: "setUnlockingPrivateMessage", + target: "unlockingPrivate", + }, }, }, sendingViaHttp: { @@ -516,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", + }, + }, + }, }, }, @@ -546,6 +609,8 @@ export const chatMachine = setup({ actions: "startUserSession", }, ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" }, + ChatImageReceived: { actions: "appendAIImage" }, + ChatPaywallStatusReceived: { actions: "applyPaywallStatus" }, ChatWebSocketError: { actions: "appendSocketErrorMessage" }, ChatWebSocketConnected: { actions: "setWsConnected" }, }, @@ -615,7 +680,10 @@ export const chatMachine = setup({ ChatLoadMoreHistory: { target: "loadingMore", }, - ChatQuotaExceeded: { actions: "incrementQuotaExceeded" }, + ChatUnlockPrivateMessage: { + actions: "setUnlockingPrivateMessage", + target: "unlockingPrivate", + }, // 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected // 已上提到 vipUserSession.on,子级不再声明 }, @@ -690,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", + }, + }, + }, }, }, }, diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts index 7c28ed90..12e2df89 100644 --- a/src/stores/chat/chat-state.ts +++ b/src/stores/chat/chat-state.ts @@ -18,23 +18,13 @@ export interface ChatState { * - 非 VIP / 游客:固定 false,走 HTTP */ wsConnected: boolean; - /** 游客剩余配额(次/天)—— 仅游客业务展示用 - */ - guestRemainingQuota: number; - /** 游客总配额(仅游客展示用) */ - guestTotalQuota: number; - quotaExceededTrigger: number; paywallTriggered: boolean; - paywallReason: "daily_limit" | null; + paywallReason: "daily_limit" | "photo_paywall" | "private_paywall" | null; paywallDetail: { usedToday: number; limit: number } | null; + unlockingPrivateMessageId: string | null; isLoadingMore: boolean; hasMore: boolean; historyOffset: number; - /** 游客配额加载完成标志(仅 guestSession 期间) —— default false - * - guestSession.initializing 走 always barrier:`quotaLoaded && historyLoaded` 才进 ready - * - user sessions 永不为 true(不调 loadQuota)—— OK,不影响 barrier - */ - quotaLoaded: boolean; /** history 加载完成标志(initial load —— local → network → save 跑完) * - guestSession.initializing / user sessions initializing 走 always barrier * - 不被 `loadMoreHistoryActor` 设置(翻页是另一码事) @@ -46,15 +36,12 @@ export const initialState: ChatState = { messages: [], isReplyingAI: false, wsConnected: false, - guestRemainingQuota: 0, - guestTotalQuota: 0, - quotaExceededTrigger: 0, paywallTriggered: false, paywallReason: null, paywallDetail: null, + unlockingPrivateMessageId: null, isLoadingMore: false, hasMore: true, historyOffset: 0, - quotaLoaded: false, historyLoaded: false, };