feat(chat): support ai photo viewing

This commit is contained in:
2026-06-23 10:33:20 +08:00
parent cebc8f7443
commit d81abd6efd
16 changed files with 160 additions and 45 deletions
+5 -13
View File
@@ -11,15 +11,6 @@ import { withSerwist } from "@serwist/turbopack";
* - 脚本侧:scripts/deploy/_deploy_lib.sh 同步 .next/ + public/ * - 脚本侧:scripts/deploy/_deploy_lib.sh 同步 .next/ + public/
* - 服务器启动:`cd <path> && next start -H 0.0.0.0 -p 3000` * - 服务器启动:`cd <path> && next start -H 0.0.0.0 -p 3000`
* 或用 PM2`pm2 start "next start" --name cozsweet` * 或用 PM2`pm2 start "next start" --name cozsweet`
*
* PWA 集成:`@serwist/turbopack` 在 build 阶段用 esbuild 编译 `src/app/sw.ts` 源文件。
* - Turbopack 原生支持 —— 不再需要 `--webpack` flag
* - SW 通过 Serwist Route Handlersrc/app/serwist/[[...slug]]/route.ts)在 `/serwist/sw.js` 服务
* - 客户端注册走 `<SerwistProvider swUrl="/serwist/sw.js">`(替代旧的 <SwRegister />
*
* 历史:
* - 之前用 @ducanh2912/next-pwawebpack-only),被迫用 `--webpack` 退 Turbopack
* - 切到 @serwist/turbopack 后拿回 Turbopack 的 dev/build 性能
*/ */
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
experimental: { experimental: {
@@ -27,10 +18,6 @@ const nextConfig: NextConfig = {
staticGenerationRetryCount: 0, staticGenerationRetryCount: 0,
}, },
images: { images: {
// Facebook OAuth 登录后,`picture.type(large)` 返回
// platform-lookaside.fbsbx.com/platform/profilepic/?asid=...&hash=...
// 该 URL 是**下载链接**而非可缓存图片,next/image 优化器需要显式 allowlist。
// graph.facebook.com 兜底:Graph 偶尔返回直链头像 / OG 图。
remotePatterns: [ remotePatterns: [
{ {
protocol: "https", protocol: "https",
@@ -40,6 +27,11 @@ const nextConfig: NextConfig = {
protocol: "https", protocol: "https",
hostname: "graph.facebook.com", hostname: "graph.facebook.com",
}, },
{
protocol: "https",
hostname: "**.supabase.co",
pathname: "/storage/v1/object/public/**",
},
], ],
}, },
}; };
+16 -2
View File
@@ -54,7 +54,10 @@ export function ChatScreen() {
!isGuest && !isGuest &&
state.paywallTriggered && state.paywallTriggered &&
state.paywallReason === "daily_limit"; 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); const externalBrowserPromptShownRef = useRef(false);
@@ -152,7 +155,18 @@ export function ChatScreen() {
/> />
{showExhaustedBanner ? ( {showExhaustedBanner ? (
<ChatQuotaExhaustedBanner /> <ChatQuotaExhaustedBanner
title={
showPhotoPaywallBanner
? "Unlock VIP to view Elio's photos"
: undefined
}
ctaLabel={
showPhotoPaywallBanner
? "Activate VIP to view photos"
: undefined
}
/>
) : ( ) : (
<ChatInputBar disabled={state.isReplyingAI} /> <ChatInputBar disabled={state.isReplyingAI} />
)} )}
@@ -1,25 +1,4 @@
"use client"; "use client";
/**
* ChatInputBar 输入栏
*
* 原始 Dart: lib/ui/chat/widgets/chat_input_bar.dart121 行)
*
* 状态:
* - 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 { useRef, useState } from "react";
import { useChatDispatch } from "@/stores/chat/chat-context"; import { useChatDispatch } from "@/stores/chat/chat-context";
@@ -44,7 +44,7 @@ export const ChatInputTextField = forwardRef<
onChange, onChange,
onSubmit, onSubmit,
onFocusChange, onFocusChange,
placeholder = "|Say anything thats on your mind ^U^…", placeholder = "Say anything thats on your mind ^U^…",
disabled = false, disabled = false,
autoFocus = false, autoFocus = false,
}, },
@@ -27,6 +27,8 @@ import { ROUTES } from "@/router/routes";
import styles from "./chat-quota-exhausted-banner.module.css"; import styles from "./chat-quota-exhausted-banner.module.css";
export interface ChatQuotaExhaustedBannerProps { export interface ChatQuotaExhaustedBannerProps {
title?: string;
ctaLabel?: string;
/** /**
* 自定义点击回调(不传则默认 router.push(ROUTES.subscription) * 自定义点击回调(不传则默认 router.push(ROUTES.subscription)
* - 测试时可传 mock * - 测试时可传 mock
@@ -36,9 +38,12 @@ export interface ChatQuotaExhaustedBannerProps {
} }
export function ChatQuotaExhaustedBanner({ export function ChatQuotaExhaustedBanner({
title = "The limit for free chat times\nhas been reached today",
ctaLabel = "Unlock your membership to continue",
onUnlock, onUnlock,
}: ChatQuotaExhaustedBannerProps) { }: ChatQuotaExhaustedBannerProps) {
const router = useRouter(); const router = useRouter();
const titleLines = title.split("\n");
const handleClick = () => { const handleClick = () => {
if (onUnlock) { if (onUnlock) {
@@ -56,17 +61,20 @@ export function ChatQuotaExhaustedBanner({
data-testid="chat-quota-exhausted-banner" data-testid="chat-quota-exhausted-banner"
> >
<p className={styles.text}> <p className={styles.text}>
The limit for free chat times {titleLines.map((line, index) => (
<br /> <span key={`${line}-${index}`}>
has been reached today {line}
{index < titleLines.length - 1 ? <br /> : null}
</span>
))}
</p> </p>
<button <button
type="button" type="button"
className={styles.cta} className={styles.cta}
onClick={handleClick} onClick={handleClick}
aria-label="Unlock your membership to continue" aria-label={ctaLabel}
> >
Unlock your membership to continue {ctaLabel}
</button> </button>
</div> </div>
); );
+28
View File
@@ -29,6 +29,13 @@ export interface IntimacyPayload {
relationshipStage?: string; relationshipStage?: string;
} }
export interface PaywallStatusPayload {
paywallTriggered: boolean;
showUpgrade: boolean;
imageType: string | null;
imageUrl: string | null;
}
export class ChatWebSocket { export class ChatWebSocket {
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null; private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -38,6 +45,8 @@ export class ChatWebSocket {
onTyping: ((isTyping: boolean) => void) | null = null; onTyping: ((isTyping: boolean) => void) | null = null;
onSentence: ((payload: SentencePayload) => void) | null = null; onSentence: ((payload: SentencePayload) => void) | null = null;
onVoiceReady: ((index: number, audioUrl: string) => void) | null = null; onVoiceReady: ((index: number, audioUrl: string) => void) | null = null;
onImage: ((imageUrl: string) => void) | null = null;
onPaywallStatus: ((payload: PaywallStatusPayload) => void) | null = null;
onIntimacyUpdate: ((payload: IntimacyPayload) => void) | null = null; onIntimacyUpdate: ((payload: IntimacyPayload) => void) | null = null;
onMoodUpdate: ((mood: string) => void) | null = null; onMoodUpdate: ((mood: string) => void) | null = null;
onError: ((errorMessage: string) => void) | null = null; onError: ((errorMessage: string) => void) | null = null;
@@ -104,6 +113,12 @@ export class ChatWebSocket {
relationshipStage?: string; relationshipStage?: string;
mood?: string; mood?: string;
error?: string; error?: string;
data?: {
imageUrl?: string | null;
paywallTriggered?: boolean;
showUpgrade?: boolean;
imageType?: string | null;
};
}; };
try { try {
payload = JSON.parse(data); payload = JSON.parse(data);
@@ -128,6 +143,19 @@ export class ChatWebSocket {
case "voice_ready": case "voice_ready":
this.onVoiceReady?.(payload.index ?? 0, payload.audioUrl ?? ""); this.onVoiceReady?.(payload.index ?? 0, payload.audioUrl ?? "");
break; 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": case "intimacy_update":
this.onIntimacyUpdate?.({ this.onIntimacyUpdate?.({
intimacyChange: payload.intimacyChange, intimacyChange: payload.intimacyChange,
+1
View File
@@ -12,6 +12,7 @@ export class ChatMessage {
declare readonly content: string; declare readonly content: string;
declare readonly id: string; declare readonly id: string;
declare readonly createdAt: string; declare readonly createdAt: string;
declare readonly imageUrl: string | null;
declare readonly isPrivate: boolean | null; declare readonly isPrivate: boolean | null;
declare readonly privateLocked: boolean | null; declare readonly privateLocked: boolean | null;
declare readonly privateHint: string | null; declare readonly privateHint: string | null;
+2
View File
@@ -124,6 +124,7 @@ export class ChatRepository implements IChatRepository {
role: message.role, role: message.role,
content: message.content, content: message.content,
createdAt: message.createdAt, createdAt: message.createdAt,
imageUrl: message.imageUrl,
sessionId: "", sessionId: "",
}); });
} }
@@ -135,6 +136,7 @@ export class ChatRepository implements IChatRepository {
role: local.role, role: local.role,
content: local.content, content: local.content,
createdAt: local.createdAt, createdAt: local.createdAt,
imageUrl: local.imageUrl,
}); });
} }
+4 -1
View File
@@ -11,13 +11,16 @@ export const ChatMessageSchema = z
id: z.string().default(""), id: z.string().default(""),
createdAt: z.string().default(""), createdAt: z.string().default(""),
created_at: z.string().optional(), created_at: z.string().optional(),
imageUrl: z.string().nullable().default(null),
image_url: z.string().nullable().optional(),
isPrivate: z.boolean().nullable().default(null), isPrivate: z.boolean().nullable().default(null),
privateLocked: z.boolean().nullable().default(null), privateLocked: z.boolean().nullable().default(null),
privateHint: z.string().nullable().default(null), privateHint: z.string().nullable().default(null),
}) })
.transform(({ created_at, ...data }) => ({ .transform(({ created_at, image_url, ...data }) => ({
...data, ...data,
createdAt: data.createdAt || created_at || "", createdAt: data.createdAt || created_at || "",
imageUrl: data.imageUrl || image_url || null,
})); }));
export type ChatMessageInput = z.input<typeof ChatMessageSchema>; export type ChatMessageInput = z.input<typeof ChatMessageSchema>;
+1
View File
@@ -21,6 +21,7 @@ export interface LocalMessageRow {
role: string; role: string;
content: string; content: string;
createdAt: string; createdAt: string;
imageUrl?: string | null;
sessionId: string; sessionId: string;
} }
+4
View File
@@ -22,6 +22,7 @@ export const LocalMessageSchema = z.object({
role: z.string(), role: z.string(),
content: z.string(), content: z.string(),
createdAt: z.string().default(""), createdAt: z.string().default(""),
imageUrl: z.string().nullable().default(null),
sessionId: z.string().default(""), sessionId: z.string().default(""),
}); });
@@ -33,6 +34,7 @@ export class LocalMessage {
declare readonly role: string; declare readonly role: string;
declare readonly content: string; declare readonly content: string;
declare readonly createdAt: string; declare readonly createdAt: string;
declare readonly imageUrl: string | null;
declare readonly sessionId: string; declare readonly sessionId: string;
private constructor(input: LocalMessageInput) { private constructor(input: LocalMessageInput) {
@@ -60,6 +62,7 @@ export class LocalMessage {
role: this.role, role: this.role,
content: this.content, content: this.content,
createdAt: this.createdAt, createdAt: this.createdAt,
imageUrl: this.imageUrl,
sessionId: this.sessionId, sessionId: this.sessionId,
}; };
} }
@@ -71,6 +74,7 @@ export class LocalMessage {
role: row.role, role: row.role,
content: row.content, content: row.content,
createdAt: row.createdAt, createdAt: row.createdAt,
imageUrl: row.imageUrl ?? null,
sessionId: row.sessionId, sessionId: row.sessionId,
}); });
} }
+8
View File
@@ -26,6 +26,14 @@ export type ChatEvent =
| { type: "ChatSendMessage"; content: string } | { type: "ChatSendMessage"; content: string }
| { type: "ChatSendImage"; imageBase64: string } | { type: "ChatSendImage"; imageBase64: string }
| { type: "ChatLoadMoreHistory" } | { type: "ChatLoadMoreHistory" }
| { type: "ChatImageReceived"; imageUrl: string }
| {
type: "ChatPaywallStatusReceived";
paywallTriggered: boolean;
showUpgrade: boolean;
imageType: string | null;
imageUrl: string | null;
}
// WebSocket / AI 推句(由 chat 机器内部 actor 派回 —— "机器自驱动" // WebSocket / AI 推句(由 chat 机器内部 actor 派回 —— "机器自驱动"
| { | {
type: "ChatAISentenceReceived"; type: "ChatAISentenceReceived";
+12
View File
@@ -151,6 +151,18 @@ export const chatWebSocketActor = fromCallback<ChatEvent, { token: string }>(
done: p.done, 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) => { ws.onError = (msg) => {
log.error("[chat-machine] WS error", { errorMessage: msg }); log.error("[chat-machine] WS error", { errorMessage: msg });
sendBack({ type: "ChatWebSocketError", errorMessage: msg }); sendBack({ type: "ChatWebSocketError", errorMessage: msg });
+13
View File
@@ -56,12 +56,14 @@ export function localMessagesToUi(
content: string; content: string;
role: string; role: string;
createdAt: string; createdAt: string;
imageUrl?: string | null;
}>, }>,
): UiMessage[] { ): UiMessage[] {
return records.map((m) => ({ return records.map((m) => ({
content: m.content, content: m.content,
isFromAI: m.role === "assistant", isFromAI: m.role === "assistant",
date: messageDateFromCreatedAt(m.createdAt), date: messageDateFromCreatedAt(m.createdAt),
...(m.imageUrl ? { imageUrl: m.imageUrl } : {}),
})); }));
} }
@@ -81,6 +83,7 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
content: response.reply, content: response.reply,
isFromAI: true, isFromAI: true,
date: todayString(new Date(response.timestamp)), 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 { return {
messages: [...context.messages, reply], messages: [...context.messages, reply],
isReplyingAI: false, isReplyingAI: false,
+50
View File
@@ -261,6 +261,54 @@ export const chatMachine = setup({
return { messages, isReplyingAI: false }; 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 }) => ({ incrementQuotaExceeded: assign(({ context }) => ({
quotaExceededTrigger: context.quotaExceededTrigger + 1, quotaExceededTrigger: context.quotaExceededTrigger + 1,
})), })),
@@ -546,6 +594,8 @@ export const chatMachine = setup({
actions: "startUserSession", actions: "startUserSession",
}, },
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" }, ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
ChatImageReceived: { actions: "appendAIImage" },
ChatPaywallStatusReceived: { actions: "applyPaywallStatus" },
ChatWebSocketError: { actions: "appendSocketErrorMessage" }, ChatWebSocketError: { actions: "appendSocketErrorMessage" },
ChatWebSocketConnected: { actions: "setWsConnected" }, ChatWebSocketConnected: { actions: "setWsConnected" },
}, },
+1 -1
View File
@@ -25,7 +25,7 @@ export interface ChatState {
guestTotalQuota: number; guestTotalQuota: number;
quotaExceededTrigger: number; quotaExceededTrigger: number;
paywallTriggered: boolean; paywallTriggered: boolean;
paywallReason: "daily_limit" | null; paywallReason: "daily_limit" | "photo_paywall" | null;
paywallDetail: { usedToday: number; limit: number } | null; paywallDetail: { usedToday: number; limit: number } | null;
isLoadingMore: boolean; isLoadingMore: boolean;
hasMore: boolean; hasMore: boolean;