feat(chat): support ai photo viewing
This commit is contained in:
+5
-13
@@ -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 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 = {
|
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/**",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.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 { 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 that’s on your mind ^U^…",
|
placeholder = "Say anything that’s 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user