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..55a72158 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -54,7 +54,10 @@ export function ChatScreen() { !isGuest && state.paywallTriggered && state.paywallReason === "daily_limit"; - const showExhaustedBanner = showGuestQuotaBanner || showDailyLimitBanner; + const showPhotoPaywallBanner = + state.paywallTriggered && state.paywallReason === "photo_paywall"; + const showExhaustedBanner = + showGuestQuotaBanner || showDailyLimitBanner || showPhotoPaywallBanner; const externalBrowserPromptShownRef = useRef(false); @@ -152,7 +155,18 @@ export function ChatScreen() { /> {showExhaustedBanner ? ( - + ) : ( )} 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..08c3069f 100644 --- a/src/app/chat/components/chat-quota-exhausted-banner.tsx +++ b/src/app/chat/components/chat-quota-exhausted-banner.tsx @@ -27,6 +27,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 +38,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 +61,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/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/repositories/chat_repository.ts b/src/data/repositories/chat_repository.ts index a7146450..8b42c8be 100644 --- a/src/data/repositories/chat_repository.ts +++ b/src/data/repositories/chat_repository.ts @@ -124,6 +124,7 @@ export class ChatRepository implements IChatRepository { role: message.role, content: message.content, createdAt: message.createdAt, + imageUrl: message.imageUrl, sessionId: "", }); } @@ -135,6 +136,7 @@ export class ChatRepository implements IChatRepository { role: local.role, content: local.content, createdAt: local.createdAt, + imageUrl: local.imageUrl, }); } 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/storage/chat/local_chat_db.ts b/src/data/storage/chat/local_chat_db.ts index 53edcdea..3b78ced5 100644 --- a/src/data/storage/chat/local_chat_db.ts +++ b/src/data/storage/chat/local_chat_db.ts @@ -21,6 +21,7 @@ export interface LocalMessageRow { role: string; content: string; createdAt: string; + imageUrl?: string | null; sessionId: string; } diff --git a/src/data/storage/chat/local_message.ts b/src/data/storage/chat/local_message.ts index 72f1e5d7..d5b0dc04 100644 --- a/src/data/storage/chat/local_message.ts +++ b/src/data/storage/chat/local_message.ts @@ -22,6 +22,7 @@ export const LocalMessageSchema = z.object({ role: z.string(), content: z.string(), createdAt: z.string().default(""), + imageUrl: z.string().nullable().default(null), sessionId: z.string().default(""), }); @@ -33,6 +34,7 @@ export class LocalMessage { declare readonly role: string; declare readonly content: string; declare readonly createdAt: string; + declare readonly imageUrl: string | null; declare readonly sessionId: string; private constructor(input: LocalMessageInput) { @@ -60,6 +62,7 @@ export class LocalMessage { role: this.role, content: this.content, createdAt: this.createdAt, + imageUrl: this.imageUrl, sessionId: this.sessionId, }; } @@ -71,6 +74,7 @@ export class LocalMessage { role: row.role, content: row.content, createdAt: row.createdAt, + imageUrl: row.imageUrl ?? null, sessionId: row.sessionId, }); } diff --git a/src/stores/chat/chat-events.ts b/src/stores/chat/chat-events.ts index 066bb270..a7723e32 100644 --- a/src/stores/chat/chat-events.ts +++ b/src/stores/chat/chat-events.ts @@ -26,6 +26,14 @@ export type ChatEvent = | { type: "ChatSendMessage"; content: string } | { type: "ChatSendImage"; imageBase64: 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"; diff --git a/src/stores/chat/chat-machine.actors.ts b/src/stores/chat/chat-machine.actors.ts index a70e119f..03ecce1d 100644 --- a/src/stores/chat/chat-machine.actors.ts +++ b/src/stores/chat/chat-machine.actors.ts @@ -151,6 +151,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..e0648532 100644 --- a/src/stores/chat/chat-machine.helpers.ts +++ b/src/stores/chat/chat-machine.helpers.ts @@ -56,12 +56,14 @@ export function localMessagesToUi( content: string; role: string; createdAt: string; + imageUrl?: string | null; }>, ): UiMessage[] { return records.map((m) => ({ content: m.content, isFromAI: m.role === "assistant", date: messageDateFromCreatedAt(m.createdAt), + ...(m.imageUrl ? { imageUrl: m.imageUrl } : {}), })); } @@ -81,6 +83,7 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage { content: response.reply, isFromAI: true, date: todayString(new Date(response.timestamp)), + ...(response.imageUrl ? { imageUrl: response.imageUrl } : {}), }; } @@ -120,6 +123,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, diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index 3768a476..833a1ec8 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -261,6 +261,54 @@ export const chatMachine = setup({ return { messages, isReplyingAI: false }; }), + 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, + }; + }), + incrementQuotaExceeded: assign(({ context }) => ({ quotaExceededTrigger: context.quotaExceededTrigger + 1, })), @@ -546,6 +594,8 @@ export const chatMachine = setup({ actions: "startUserSession", }, ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" }, + ChatImageReceived: { actions: "appendAIImage" }, + ChatPaywallStatusReceived: { actions: "applyPaywallStatus" }, ChatWebSocketError: { actions: "appendSocketErrorMessage" }, ChatWebSocketConnected: { actions: "setWsConnected" }, }, diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts index 7c28ed90..e48d5024 100644 --- a/src/stores/chat/chat-state.ts +++ b/src/stores/chat/chat-state.ts @@ -25,7 +25,7 @@ export interface ChatState { guestTotalQuota: number; quotaExceededTrigger: number; paywallTriggered: boolean; - paywallReason: "daily_limit" | null; + paywallReason: "daily_limit" | "photo_paywall" | null; paywallDetail: { usedToday: number; limit: number } | null; isLoadingMore: boolean; hasMore: boolean;