feat(chat): support ai photo viewing
This commit is contained in:
+6
-14
@@ -11,15 +11,6 @@ import { withSerwist } from "@serwist/turbopack";
|
||||
* - 脚本侧:scripts/deploy/_deploy_lib.sh 同步 .next/ + public/
|
||||
* - 服务器启动:`cd <path> && 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` 服务
|
||||
* - 客户端注册走 `<SerwistProvider swUrl="/serwist/sw.js">`(替代旧的 <SwRegister />)
|
||||
*
|
||||
* 历史:
|
||||
* - 之前用 @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);
|
||||
export default withSerwist(nextConfig);
|
||||
|
||||
@@ -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 ? (
|
||||
<ChatQuotaExhaustedBanner />
|
||||
<ChatQuotaExhaustedBanner
|
||||
title={
|
||||
showPhotoPaywallBanner
|
||||
? "Unlock VIP to view Elio's photos"
|
||||
: undefined
|
||||
}
|
||||
ctaLabel={
|
||||
showPhotoPaywallBanner
|
||||
? "Activate VIP to view photos"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ChatInputBar disabled={state.isReplyingAI} />
|
||||
)}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<p className={styles.text}>
|
||||
The limit for free chat times
|
||||
<br />
|
||||
has been reached today
|
||||
{titleLines.map((line, index) => (
|
||||
<span key={`${line}-${index}`}>
|
||||
{line}
|
||||
{index < titleLines.length - 1 ? <br /> : null}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cta}
|
||||
onClick={handleClick}
|
||||
aria-label="Unlock your membership to continue"
|
||||
aria-label={ctaLabel}
|
||||
>
|
||||
Unlock your membership to continue
|
||||
{ctaLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<typeof setTimeout> | 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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof ChatMessageSchema>;
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface LocalMessageRow {
|
||||
role: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
imageUrl?: string | null;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -151,6 +151,18 @@ export const chatWebSocketActor = fromCallback<ChatEvent, { token: string }>(
|
||||
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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user