Merge branch 'dev' into test
This commit is contained in:
@@ -3,8 +3,8 @@ NEXT_PUBLIC_APP_ENV=test
|
|||||||
# NextAuth v4 —— OAuth callback **公**网 base URL(**必**须与**公**网域名**一**致)
|
# NextAuth v4 —— OAuth callback **公**网 base URL(**必**须与**公**网域名**一**致)
|
||||||
NEXTAUTH_URL=https://frontend-test.banlv-ai.com
|
NEXTAUTH_URL=https://frontend-test.banlv-ai.com
|
||||||
NEXTAUTH_URL_INTERNAL=http://localhost:3000
|
NEXTAUTH_URL_INTERNAL=http://localhost:3000
|
||||||
NEXT_PUBLIC_API_BASE_URL=https://api.cozsweet.com
|
NEXT_PUBLIC_API_BASE_URL=https://testapi.banlv-ai.com
|
||||||
NEXT_PUBLIC_WS_BASE_URL=wss://api.cozsweet.com/ws
|
NEXT_PUBLIC_WS_BASE_URL=wss://testapi.banlv-ai.com/ws
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R
|
||||||
|
|
||||||
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
|
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
|
||||||
|
|||||||
+6
-14
@@ -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,8 +27,13 @@ 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/**",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withSerwist(nextConfig);
|
export default withSerwist(nextConfig);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { LoginStatus } from "@/data/dto/auth";
|
|||||||
import { AppStorage } from "@/data/storage/app/app_storage";
|
import { AppStorage } from "@/data/storage/app/app_storage";
|
||||||
import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
||||||
import { UserStorage } from "@/data/storage/user/user_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 { AppEnvUtil, BrowserDetector, todayString, UrlLauncherUtil } from "@/utils";
|
||||||
import { ROUTE_BUILDERS, ROUTES } from "@/router/routes";
|
import { ROUTE_BUILDERS, ROUTES } from "@/router/routes";
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ function deriveIsGuest(loginStatus: LoginStatus): boolean {
|
|||||||
|
|
||||||
export function ChatScreen() {
|
export function ChatScreen() {
|
||||||
const state = useChatState();
|
const state = useChatState();
|
||||||
|
const chatDispatch = useChatDispatch();
|
||||||
const authState = useAuthState();
|
const authState = useAuthState();
|
||||||
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
|
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@@ -42,19 +43,22 @@ export function ChatScreen() {
|
|||||||
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
|
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
|
||||||
const isGuest = deriveIsGuest(authState.loginStatus);
|
const isGuest = deriveIsGuest(authState.loginStatus);
|
||||||
|
|
||||||
// 游客配额耗尽 → 显示 Unlock CTA banner(替代 ChatInputBar)
|
// 消息数量限制由后端统一返回 blocked/daily_limit,前端不再处理本地额度。
|
||||||
// 三重条件:游客 + 配额加载完 + 用户被配额 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;
|
|
||||||
const showDailyLimitBanner =
|
const showDailyLimitBanner =
|
||||||
!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 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);
|
const externalBrowserPromptShownRef = useRef(false);
|
||||||
|
|
||||||
@@ -127,6 +131,10 @@ export function ChatScreen() {
|
|||||||
UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.splash);
|
UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.splash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleUnlockPrivateMessage(messageId: string): void {
|
||||||
|
chatDispatch({ type: "ChatUnlockPrivateMessage", messageId });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell>
|
<MobileShell>
|
||||||
<div className={styles.shell}>
|
<div className={styles.shell}>
|
||||||
@@ -149,10 +157,20 @@ export function ChatScreen() {
|
|||||||
messages={state.messages}
|
messages={state.messages}
|
||||||
isReplyingAI={state.isReplyingAI}
|
isReplyingAI={state.isReplyingAI}
|
||||||
isGuest={isGuest}
|
isGuest={isGuest}
|
||||||
|
unlockingPrivateMessageId={state.unlockingPrivateMessageId}
|
||||||
|
onUnlockPrivateMessage={handleUnlockPrivateMessage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showExhaustedBanner ? (
|
{showInlineUpgradeBanner ? (
|
||||||
<ChatQuotaExhaustedBanner />
|
<ChatQuotaExhaustedBanner
|
||||||
|
title={inlineUpgradeTitle}
|
||||||
|
ctaLabel={inlineUpgradeCta}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showDailyLimitBanner ? (
|
||||||
|
<ChatQuotaExhaustedBanner
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ChatInputBar disabled={state.isReplyingAI} />
|
<ChatInputBar disabled={state.isReplyingAI} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -28,9 +28,16 @@ export interface ChatAreaProps {
|
|||||||
messages: readonly UiMessage[];
|
messages: readonly UiMessage[];
|
||||||
isReplyingAI: boolean;
|
isReplyingAI: boolean;
|
||||||
isGuest: 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<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const prevLengthRef = useRef(messages.length);
|
const prevLengthRef = useRef(messages.length);
|
||||||
|
|
||||||
@@ -49,7 +56,11 @@ export function ChatArea({ messages, isReplyingAI }: ChatAreaProps) {
|
|||||||
<main ref={scrollRef} className={styles.area} aria-label="Chat messages">
|
<main ref={scrollRef} className={styles.area} aria-label="Chat messages">
|
||||||
<AiDisclosureBanner />
|
<AiDisclosureBanner />
|
||||||
|
|
||||||
{renderMessagesWithDateHeaders(messages)}
|
{renderMessagesWithDateHeaders(
|
||||||
|
messages,
|
||||||
|
unlockingPrivateMessageId,
|
||||||
|
onUnlockPrivateMessage,
|
||||||
|
)}
|
||||||
|
|
||||||
{isReplyingAI && <LottieMessageBubble />}
|
{isReplyingAI && <LottieMessageBubble />}
|
||||||
</main>
|
</main>
|
||||||
@@ -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 }> = [];
|
const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = [];
|
||||||
|
|
||||||
messages.forEach((m, i) => {
|
messages.forEach((m, i) => {
|
||||||
@@ -73,9 +88,17 @@ function renderMessagesWithDateHeaders(messages: readonly UiMessage[]) {
|
|||||||
) : (
|
) : (
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
key={item.key}
|
key={item.key}
|
||||||
|
id={item.message.id}
|
||||||
content={item.message.content}
|
content={item.message.content}
|
||||||
imageUrl={item.message.imageUrl}
|
imageUrl={item.message.imageUrl}
|
||||||
isFromAI={item.message.isFromAI}
|
isFromAI={item.message.isFromAI}
|
||||||
|
privateLocked={item.message.privateLocked}
|
||||||
|
privateHint={item.message.privateHint}
|
||||||
|
isUnlockingPrivate={
|
||||||
|
item.message.id != null &&
|
||||||
|
item.message.id === unlockingPrivateMessageId
|
||||||
|
}
|
||||||
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,9 +3,7 @@
|
|||||||
* ChatQuotaExhaustedBanner 游客配额耗尽横幅
|
* ChatQuotaExhaustedBanner 游客配额耗尽横幅
|
||||||
*
|
*
|
||||||
* 何时显示:
|
* 何时显示:
|
||||||
* - isGuest === true(仅游客业务事实"非游客无配额上限")
|
* - 后端返回 blocked=true && blockReason="daily_limit"
|
||||||
* - quotaLoaded === true(避免一进 /chat 就跳 banner)
|
|
||||||
* - guestTotalQuota <= 0(用 total 而非 remaining —— 配额刚减为 0 不立即跳)
|
|
||||||
*
|
*
|
||||||
* 视觉规格(与设计稿对齐):
|
* 视觉规格(与设计稿对齐):
|
||||||
* - 粉→品红渐变背景(与 splash 渐变一致)
|
* - 粉→品红渐变背景(与 splash 渐变一致)
|
||||||
@@ -27,6 +25,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 +36,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 +59,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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,5 +22,6 @@ export * from "./message-bubble";
|
|||||||
export * from "./message-content";
|
export * from "./message-content";
|
||||||
export * from "./pwa-install-dialog";
|
export * from "./pwa-install-dialog";
|
||||||
export * from "./pwa-install-overlay";
|
export * from "./pwa-install-overlay";
|
||||||
|
export * from "./private-message-card";
|
||||||
export * from "./text-bubble";
|
export * from "./text-bubble";
|
||||||
export * from "./user-message-avatar";
|
export * from "./user-message-avatar";
|
||||||
|
|||||||
@@ -15,12 +15,26 @@ import { MessageContent } from "./message-content";
|
|||||||
import styles from "./chat-area.module.css";
|
import styles from "./chat-area.module.css";
|
||||||
|
|
||||||
export interface MessageBubbleProps {
|
export interface MessageBubbleProps {
|
||||||
|
id?: string;
|
||||||
content: string;
|
content: string;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
isFromAI: boolean;
|
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();
|
const { avatarUrl } = useUserState();
|
||||||
|
|
||||||
if (isFromAI) {
|
if (isFromAI) {
|
||||||
@@ -29,9 +43,14 @@ export function MessageBubble({ content, imageUrl, isFromAI }: MessageBubbleProp
|
|||||||
<MessageAvatar isFromAI={true} />
|
<MessageAvatar isFromAI={true} />
|
||||||
<div style={{ width: 8 }} />
|
<div style={{ width: 8 }} />
|
||||||
<MessageContent
|
<MessageContent
|
||||||
|
messageId={id}
|
||||||
content={content}
|
content={content}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
isFromAI={true}
|
isFromAI={true}
|
||||||
|
privateLocked={privateLocked}
|
||||||
|
privateHint={privateHint}
|
||||||
|
isUnlockingPrivate={isUnlockingPrivate}
|
||||||
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
/>
|
/>
|
||||||
<div style={{ width: 43 }} /> {/* 占位:保持与头像宽度一致 */}
|
<div style={{ width: 43 }} /> {/* 占位:保持与头像宽度一致 */}
|
||||||
</div>
|
</div>
|
||||||
@@ -41,7 +60,16 @@ export function MessageBubble({ content, imageUrl, isFromAI }: MessageBubbleProp
|
|||||||
return (
|
return (
|
||||||
<div className={styles.bubbleRowUser} aria-label="User message">
|
<div className={styles.bubbleRowUser} aria-label="User message">
|
||||||
<div style={{ width: 43 }} /> {/* 占位 */}
|
<div style={{ width: 43 }} /> {/* 占位 */}
|
||||||
<MessageContent content={content} imageUrl={imageUrl} isFromAI={false} />
|
<MessageContent
|
||||||
|
messageId={id}
|
||||||
|
content={content}
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
isFromAI={false}
|
||||||
|
privateLocked={privateLocked}
|
||||||
|
privateHint={privateHint}
|
||||||
|
isUnlockingPrivate={isUnlockingPrivate}
|
||||||
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
|
/>
|
||||||
<div style={{ width: 8 }} />
|
<div style={{ width: 8 }} />
|
||||||
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
|
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,19 +10,35 @@
|
|||||||
* - 两者都有:图片在上,文字在下
|
* - 两者都有:图片在上,文字在下
|
||||||
*/
|
*/
|
||||||
import { ImageBubble } from "./image-bubble";
|
import { ImageBubble } from "./image-bubble";
|
||||||
|
import { PrivateMessageCard } from "./private-message-card";
|
||||||
import { TextBubble } from "./text-bubble";
|
import { TextBubble } from "./text-bubble";
|
||||||
|
|
||||||
export interface MessageContentProps {
|
export interface MessageContentProps {
|
||||||
|
messageId?: string;
|
||||||
content: string;
|
content: string;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
isFromAI: boolean;
|
isFromAI: boolean;
|
||||||
|
privateLocked?: boolean | null;
|
||||||
|
privateHint?: string | null;
|
||||||
|
isUnlockingPrivate?: boolean;
|
||||||
|
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IMAGE_PLACEHOLDER = "[图片]";
|
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 hasImage = imageUrl != null && imageUrl.length > 0;
|
||||||
const hasText = content.length > 0 && content !== IMAGE_PLACEHOLDER;
|
const hasText = content.length > 0 && content !== IMAGE_PLACEHOLDER;
|
||||||
|
const isLockedPrivateMessage = privateLocked === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -37,8 +53,22 @@ export function MessageContent({ content, imageUrl, isFromAI }: MessageContentPr
|
|||||||
gap: 8,
|
gap: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasImage && imageUrl && <ImageBubble imageUrl={imageUrl} />}
|
{isLockedPrivateMessage ? (
|
||||||
{hasText && <TextBubble content={content} isFromAI={isFromAI} />}
|
<PrivateMessageCard
|
||||||
|
hint={privateHint}
|
||||||
|
isUnlocking={isUnlockingPrivate}
|
||||||
|
onUnlock={
|
||||||
|
messageId
|
||||||
|
? () => onUnlockPrivateMessage?.(messageId)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{hasImage && imageUrl && <ImageBubble imageUrl={imageUrl} />}
|
||||||
|
{hasText && <TextBubble content={content} isFromAI={isFromAI} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className={styles.card} role="group" aria-label="Locked private message">
|
||||||
|
<div className={styles.iconWrap} aria-hidden="true">
|
||||||
|
<LockKeyhole className={styles.icon} size={22} />
|
||||||
|
</div>
|
||||||
|
<p className={styles.hint}>
|
||||||
|
{hint && hint.length > 0
|
||||||
|
? hint
|
||||||
|
: "Elio has a private message for you."}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.button}
|
||||||
|
disabled={isUnlocking || !onUnlock}
|
||||||
|
onClick={onUnlock}
|
||||||
|
>
|
||||||
|
{isUnlocking ? "Unlocking..." : "Unlock private message"}
|
||||||
|
</button>
|
||||||
|
</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;
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ export * from "./chat_message";
|
|||||||
export * from "./chat_send_response";
|
export * from "./chat_send_response";
|
||||||
export * from "./chat_sync_data";
|
export * from "./chat_sync_data";
|
||||||
export * from "./chat_sync_request";
|
export * from "./chat_sync_request";
|
||||||
export * from "./guest_chat_quota";
|
|
||||||
export * from "./image_upload_response";
|
export * from "./image_upload_response";
|
||||||
export * from "./send_message_request";
|
export * from "./send_message_request";
|
||||||
export * from "./stt_data";
|
export * from "./stt_data";
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const UiMessageSchema = z.object({
|
export const UiMessageSchema = z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
content: z.string(),
|
content: z.string(),
|
||||||
isFromAI: z.boolean(),
|
isFromAI: z.boolean(),
|
||||||
/** 日期分隔条使用的本地日期(YYYY-MM-DD) */
|
/** 日期分隔条使用的本地日期(YYYY-MM-DD) */
|
||||||
@@ -16,6 +17,9 @@ export const UiMessageSchema = z.object({
|
|||||||
imageUrl: z.string().optional(),
|
imageUrl: z.string().optional(),
|
||||||
/** 语音 URL */
|
/** 语音 URL */
|
||||||
voiceUrl: z.string().optional(),
|
voiceUrl: z.string().optional(),
|
||||||
|
isPrivate: z.boolean().nullable().optional(),
|
||||||
|
privateLocked: z.boolean().nullable().optional(),
|
||||||
|
privateHint: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UiMessage = z.infer<typeof UiMessageSchema>;
|
export type UiMessage = z.infer<typeof UiMessageSchema>;
|
||||||
|
|||||||
@@ -68,6 +68,39 @@ export class ChatRepository implements IChatRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 把本地缓存中的私密消息标记为已解锁。 */
|
||||||
|
async markPrivateMessageUnlockedInLocal(
|
||||||
|
messageId: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<Result<void>> {
|
||||||
|
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)操作 ============
|
// ============ 本地(Dexie)操作 ============
|
||||||
|
|
||||||
/** 把一条消息写入本地存储。 */
|
/** 把一条消息写入本地存储。 */
|
||||||
@@ -124,6 +157,10 @@ 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,
|
||||||
|
isPrivate: message.isPrivate,
|
||||||
|
privateLocked: message.privateLocked,
|
||||||
|
privateHint: message.privateHint,
|
||||||
sessionId: "",
|
sessionId: "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -135,6 +172,10 @@ 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,
|
||||||
|
isPrivate: local.isPrivate,
|
||||||
|
privateLocked: local.privateLocked,
|
||||||
|
privateHint: local.privateHint,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ export interface IChatRepository {
|
|||||||
/** 解锁私密消息。 */
|
/** 解锁私密消息。 */
|
||||||
unlockPrivateMessage(messageId: string): Promise<Result<UnlockPrivateResponse>>;
|
unlockPrivateMessage(messageId: string): Promise<Result<UnlockPrivateResponse>>;
|
||||||
|
|
||||||
|
/** 把本地缓存中的私密消息标记为已解锁。 */
|
||||||
|
markPrivateMessageUnlockedInLocal(
|
||||||
|
messageId: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<Result<void>>;
|
||||||
|
|
||||||
/** 把一条消息写入本地存储。 */
|
/** 把一条消息写入本地存储。 */
|
||||||
saveMessageToLocal(message: ChatMessage): Promise<Result<void>>;
|
saveMessageToLocal(message: ChatMessage): Promise<Result<void>>;
|
||||||
|
|
||||||
|
|||||||
@@ -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,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<typeof GuestChatQuotaSchema>;
|
|
||||||
export type GuestChatQuotaData = z.output<typeof GuestChatQuotaSchema>;
|
|
||||||
@@ -7,7 +7,6 @@ export * from "./chat_message";
|
|||||||
export * from "./chat_send_response";
|
export * from "./chat_send_response";
|
||||||
export * from "./chat_sync_data";
|
export * from "./chat_sync_data";
|
||||||
export * from "./chat_sync_request";
|
export * from "./chat_sync_request";
|
||||||
export * from "./guest_chat_quota";
|
|
||||||
export * from "./image_upload_response";
|
export * from "./image_upload_response";
|
||||||
export * from "./send_message_request";
|
export * from "./send_message_request";
|
||||||
export * from "./stt_data";
|
export * from "./stt_data";
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ export const UnlockPrivateReasonSchema = z.enum([
|
|||||||
export const UnlockPrivateResponseSchema = z.object({
|
export const UnlockPrivateResponseSchema = z.object({
|
||||||
unlocked: z.boolean(),
|
unlocked: z.boolean(),
|
||||||
content: z.string().nullable(),
|
content: z.string().nullable(),
|
||||||
showUpgrade: z.boolean(),
|
showUpgrade: z.boolean().default(false),
|
||||||
paywallTriggered: z.boolean(),
|
paywallTriggered: z.boolean().default(false),
|
||||||
privateFreeLimit: z.number(),
|
privateFreeLimit: z.number().default(0),
|
||||||
privateUsedToday: z.number(),
|
privateUsedToday: z.number().default(0),
|
||||||
reason: UnlockPrivateReasonSchema,
|
reason: UnlockPrivateReasonSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export * from "../dto/chat/chat_message";
|
|||||||
export * from "../dto/chat/chat_send_response";
|
export * from "../dto/chat/chat_send_response";
|
||||||
export * from "../dto/chat/chat_sync_data";
|
export * from "../dto/chat/chat_sync_data";
|
||||||
export * from "../dto/chat/chat_sync_request";
|
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/image_upload_response";
|
||||||
export * from "../dto/chat/send_message_request";
|
export * from "../dto/chat/send_message_request";
|
||||||
export * from "../dto/chat/stt_data";
|
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_send_response";
|
||||||
export * from "../schemas/chat/chat_sync_data";
|
export * from "../schemas/chat/chat_sync_data";
|
||||||
export * from "../schemas/chat/chat_sync_request";
|
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/image_upload_response";
|
||||||
export * from "../schemas/chat/send_message_request";
|
export * from "../schemas/chat/send_message_request";
|
||||||
export * from "../schemas/chat/stt_data";
|
export * from "../schemas/chat/stt_data";
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,17 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
export class ChatStorage {
|
||||||
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 {
|
|
||||||
private static _instance: ChatStorage | null = null;
|
private static _instance: ChatStorage | null = null;
|
||||||
|
|
||||||
static getInstance(): ChatStorage {
|
static getInstance(): ChatStorage {
|
||||||
@@ -24,65 +13,6 @@ export class ChatStorage implements IChatStorage {
|
|||||||
ChatStorage._instance = null;
|
ChatStorage._instance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- guest daily chat quota ----
|
// 本地游客消息数量额度逻辑已停用:
|
||||||
|
// 游客 / 非游客的消息数量限制统一由后端接口返回 blocked/daily_limit。
|
||||||
async getGuestDailyChatQuota(): Promise<ResultT<GuestChatQuotaData | null>> {
|
|
||||||
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<ResultT<void>> {
|
|
||||||
const value: GuestChatQuotaData = { remaining, date };
|
|
||||||
return SpAsyncUtil.setJson(StorageKeys.guestChatQuota, value, GuestChatQuotaSchema);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearGuestDailyChatQuota(): Promise<ResultT<void>> {
|
|
||||||
return SpAsyncUtil.remove(StorageKeys.guestChatQuota);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- guest total quota ----
|
|
||||||
|
|
||||||
async getGuestTotalQuota(): Promise<ResultT<number | null>> {
|
|
||||||
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<ResultT<void>> {
|
|
||||||
return SpAsyncUtil.setJson(StorageKeys.guestTotalQuotaRemaining, remaining, z.number());
|
|
||||||
}
|
|
||||||
|
|
||||||
clearGuestTotalQuota(): Promise<ResultT<void>> {
|
|
||||||
return SpAsyncUtil.remove(StorageKeys.guestTotalQuotaRemaining);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Result<GuestChatQuotaData | null>>;
|
|
||||||
setGuestDailyChatQuota(
|
|
||||||
remaining: number,
|
|
||||||
date: string,
|
|
||||||
): Promise<Result<void>>;
|
|
||||||
clearGuestDailyChatQuota(): Promise<Result<void>>;
|
|
||||||
|
|
||||||
getGuestTotalQuota(): Promise<Result<number | null>>;
|
|
||||||
setGuestTotalQuota(remaining: number): Promise<Result<void>>;
|
|
||||||
clearGuestTotalQuota(): Promise<Result<void>>;
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./chat_storage";
|
export * from "./chat_storage";
|
||||||
export * from "./ichat_storage";
|
|
||||||
export * from "./local_chat_db";
|
export * from "./local_chat_db";
|
||||||
export * from "./local_chat_storage";
|
export * from "./local_chat_storage";
|
||||||
export * from "./local_message";
|
export * from "./local_message";
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export interface LocalMessageRow {
|
|||||||
role: string;
|
role: string;
|
||||||
content: string;
|
content: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
isPrivate?: boolean | null;
|
||||||
|
privateLocked?: boolean | null;
|
||||||
|
privateHint?: string | null;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ 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),
|
||||||
|
isPrivate: z.boolean().nullable().default(null),
|
||||||
|
privateLocked: z.boolean().nullable().default(null),
|
||||||
|
privateHint: z.string().nullable().default(null),
|
||||||
sessionId: z.string().default(""),
|
sessionId: z.string().default(""),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,6 +37,10 @@ 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 isPrivate: boolean | null;
|
||||||
|
declare readonly privateLocked: boolean | null;
|
||||||
|
declare readonly privateHint: string | null;
|
||||||
declare readonly sessionId: string;
|
declare readonly sessionId: string;
|
||||||
|
|
||||||
private constructor(input: LocalMessageInput) {
|
private constructor(input: LocalMessageInput) {
|
||||||
@@ -60,6 +68,10 @@ export class LocalMessage {
|
|||||||
role: this.role,
|
role: this.role,
|
||||||
content: this.content,
|
content: this.content,
|
||||||
createdAt: this.createdAt,
|
createdAt: this.createdAt,
|
||||||
|
imageUrl: this.imageUrl,
|
||||||
|
isPrivate: this.isPrivate,
|
||||||
|
privateLocked: this.privateLocked,
|
||||||
|
privateHint: this.privateHint,
|
||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -71,6 +83,10 @@ 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,
|
||||||
|
isPrivate: row.isPrivate ?? null,
|
||||||
|
privateLocked: row.privateLocked ?? null,
|
||||||
|
privateHint: row.privateHint ?? null,
|
||||||
sessionId: row.sessionId,
|
sessionId: row.sessionId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export * from "./app/app_storage";
|
|||||||
export * from "./auth/auth_storage";
|
export * from "./auth/auth_storage";
|
||||||
export * from "./auth/iauth_storage";
|
export * from "./auth/iauth_storage";
|
||||||
export * from "./chat/chat_storage";
|
export * from "./chat/chat_storage";
|
||||||
export * from "./chat/ichat_storage";
|
|
||||||
export * from "./chat/local_chat_db";
|
export * from "./chat/local_chat_db";
|
||||||
export * from "./chat/local_chat_storage";
|
export * from "./chat/local_chat_storage";
|
||||||
export * from "./chat/local_message";
|
export * from "./chat/local_message";
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ export const StorageKeys = {
|
|||||||
|
|
||||||
// chat
|
// chat
|
||||||
chatHistory: "chat_history",
|
chatHistory: "chat_history",
|
||||||
guestChatQuota: "guest_chat_quota",
|
|
||||||
guestTotalQuotaRemaining: "guest_total_quota_remaining",
|
|
||||||
|
|
||||||
// pwa / app info
|
// pwa / app info
|
||||||
lastPwaDialogShown: "last_pwa_dialog_shown",
|
lastPwaDialogShown: "last_pwa_dialog_shown",
|
||||||
|
|||||||
@@ -21,19 +21,13 @@ import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
|
|||||||
interface ChatState {
|
interface ChatState {
|
||||||
messages: MachineContext["messages"];
|
messages: MachineContext["messages"];
|
||||||
isReplyingAI: boolean;
|
isReplyingAI: boolean;
|
||||||
/** 游客剩余配额(次/天)—— 业务展示用,鉴权判断由 chat-screen 派生 loginStatus */
|
|
||||||
guestRemainingQuota: number;
|
|
||||||
/** 游客总配额 */
|
|
||||||
guestTotalQuota: number;
|
|
||||||
quotaExceededTrigger: number;
|
|
||||||
paywallTriggered: boolean;
|
paywallTriggered: boolean;
|
||||||
paywallReason: MachineContext["paywallReason"];
|
paywallReason: MachineContext["paywallReason"];
|
||||||
paywallDetail: MachineContext["paywallDetail"];
|
paywallDetail: MachineContext["paywallDetail"];
|
||||||
|
unlockingPrivateMessageId: MachineContext["unlockingPrivateMessageId"];
|
||||||
isLoadingMore: boolean;
|
isLoadingMore: boolean;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
historyOffset: number;
|
historyOffset: number;
|
||||||
/** 游客配额加载完成标志(仅 guestSession 期间) —— UI 可用此显示 loading */
|
|
||||||
quotaLoaded: boolean;
|
|
||||||
/** history 初始加载完成标志(local → network → save 跑完) —— UI 可用此显示 loading */
|
/** history 初始加载完成标志(local → network → save 跑完) —— UI 可用此显示 loading */
|
||||||
historyLoaded: boolean;
|
historyLoaded: boolean;
|
||||||
}
|
}
|
||||||
@@ -53,16 +47,13 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
|||||||
() => ({
|
() => ({
|
||||||
messages: state.context.messages,
|
messages: state.context.messages,
|
||||||
isReplyingAI: state.context.isReplyingAI,
|
isReplyingAI: state.context.isReplyingAI,
|
||||||
guestRemainingQuota: state.context.guestRemainingQuota,
|
|
||||||
guestTotalQuota: state.context.guestTotalQuota,
|
|
||||||
quotaExceededTrigger: state.context.quotaExceededTrigger,
|
|
||||||
paywallTriggered: state.context.paywallTriggered,
|
paywallTriggered: state.context.paywallTriggered,
|
||||||
paywallReason: state.context.paywallReason,
|
paywallReason: state.context.paywallReason,
|
||||||
paywallDetail: state.context.paywallDetail,
|
paywallDetail: state.context.paywallDetail,
|
||||||
|
unlockingPrivateMessageId: state.context.unlockingPrivateMessageId,
|
||||||
isLoadingMore: state.context.isLoadingMore,
|
isLoadingMore: state.context.isLoadingMore,
|
||||||
hasMore: state.context.hasMore,
|
hasMore: state.context.hasMore,
|
||||||
historyOffset: state.context.historyOffset,
|
historyOffset: state.context.historyOffset,
|
||||||
quotaLoaded: state.context.quotaLoaded,
|
|
||||||
historyLoaded: state.context.historyLoaded,
|
historyLoaded: state.context.historyLoaded,
|
||||||
}),
|
}),
|
||||||
[state],
|
[state],
|
||||||
|
|||||||
@@ -25,7 +25,16 @@ export type ChatEvent =
|
|||||||
// 业务事件
|
// 业务事件
|
||||||
| { type: "ChatSendMessage"; content: string }
|
| { type: "ChatSendMessage"; content: string }
|
||||||
| { type: "ChatSendImage"; imageBase64: string }
|
| { type: "ChatSendImage"; imageBase64: string }
|
||||||
|
| { type: "ChatUnlockPrivateMessage"; messageId: 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";
|
||||||
@@ -35,5 +44,4 @@ export type ChatEvent =
|
|||||||
done: boolean;
|
done: boolean;
|
||||||
}
|
}
|
||||||
| { type: "ChatWebSocketError"; errorMessage: string }
|
| { type: "ChatWebSocketError"; errorMessage: string }
|
||||||
| { type: "ChatWebSocketConnected"; userId: string }
|
| { type: "ChatWebSocketConnected"; userId: string };
|
||||||
| { type: "ChatQuotaExceeded" };
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
chatRepo,
|
chatRepo,
|
||||||
localMessagesToUi,
|
localMessagesToUi,
|
||||||
readAndSyncHistory,
|
readAndSyncHistory,
|
||||||
readGuestQuota,
|
|
||||||
sendResponseToUiMessage,
|
sendResponseToUiMessage,
|
||||||
} from "./chat-machine.helpers";
|
} from "./chat-machine.helpers";
|
||||||
import type { ChatEvent } from "./chat-events";
|
import type { ChatEvent } from "./chat-events";
|
||||||
@@ -42,20 +41,7 @@ export function getActiveChatWebSocket(): ChatWebSocket | null {
|
|||||||
return activeChatWebSocket;
|
return activeChatWebSocket;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// 本地游客消息额度 actor 已停用:消息数量限制统一交由后端 blocked/daily_limit 响应处理。
|
||||||
// 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();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Init 任务 2:拉 history 3 步流(fromPromise,返回最终结果)
|
// 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
|
// WebSocket: long-lived callback
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -151,6 +172,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 });
|
||||||
|
|||||||
@@ -5,10 +5,7 @@
|
|||||||
* - 翻页大小常量
|
* - 翻页大小常量
|
||||||
* - 仓库注入(接口类型 → 单例)
|
* - 仓库注入(接口类型 → 单例)
|
||||||
* - DTO ↔ UiMessage 映射(纯函数 —— 可单测)
|
* - DTO ↔ UiMessage 映射(纯函数 —— 可单测)
|
||||||
* - Result → number 映射(纯函数 —— 可单测)
|
* - `readAndSyncHistory()` —— local → network → save network to local 3 步
|
||||||
* - 2 个独立 init 任务的数据加载:
|
|
||||||
* - `readGuestQuota()` —— 游客日配 + 总配(仅游客有意义,纯 ChatStorage)
|
|
||||||
* - `readAndSyncHistory()` —— local → network → save network to local 3 步
|
|
||||||
*
|
*
|
||||||
* 设计目标:
|
* 设计目标:
|
||||||
* - helpers 无 XState 依赖(可独立 import / 测试)
|
* - helpers 无 XState 依赖(可独立 import / 测试)
|
||||||
@@ -23,7 +20,6 @@
|
|||||||
import type { UiMessage } from "@/data/dto/chat";
|
import type { UiMessage } from "@/data/dto/chat";
|
||||||
import { chatRepository } from "@/data/repositories/chat_repository";
|
import { chatRepository } from "@/data/repositories/chat_repository";
|
||||||
import type { IChatRepository } from "@/data/repositories/interfaces";
|
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 { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
|
||||||
import { todayString, Result, Logger } from "@/utils";
|
import { todayString, Result, Logger } from "@/utils";
|
||||||
|
|
||||||
@@ -53,15 +49,25 @@ export const chatRepo: IChatRepository = chatRepository;
|
|||||||
*/
|
*/
|
||||||
export function localMessagesToUi(
|
export function localMessagesToUi(
|
||||||
records: ReadonlyArray<{
|
records: ReadonlyArray<{
|
||||||
|
id?: string;
|
||||||
content: string;
|
content: string;
|
||||||
role: string;
|
role: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
isPrivate?: boolean | null;
|
||||||
|
privateLocked?: boolean | null;
|
||||||
|
privateHint?: string | null;
|
||||||
}>,
|
}>,
|
||||||
): UiMessage[] {
|
): UiMessage[] {
|
||||||
return records.map((m) => ({
|
return records.map((m) => ({
|
||||||
|
...(m.id ? { id: m.id } : {}),
|
||||||
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 } : {}),
|
||||||
|
...(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 {
|
export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
|
||||||
return {
|
return {
|
||||||
|
...(response.messageId ? { id: response.messageId } : {}),
|
||||||
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 +128,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,
|
||||||
@@ -129,44 +147,8 @@ export function applyHttpSendOutput(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// 本地游客消息额度读取 / 映射逻辑已停用:
|
||||||
// Result → number 映射
|
// 消息数量限制统一由后端接口返回 blocked/daily_limit,前端不再读取 ChatStorage quota。
|
||||||
// ============================================================
|
|
||||||
/** 日配额 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<number | null>,
|
|
||||||
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<number | null>, 0),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Init 任务 2:history 3 步流(local → network → save)—— `loadHistoryActor` 调
|
// Init 任务 2:history 3 步流(local → network → save)—— `loadHistoryActor` 调
|
||||||
|
|||||||
+208
-123
@@ -15,41 +15,39 @@
|
|||||||
* - `nonVipUserSession`(parent):非 VIP 用户会话 —— 不 invoke WS
|
* - `nonVipUserSession`(parent):非 VIP 用户会话 —— 不 invoke WS
|
||||||
* - `vipUserSession`(parent):VIP 用户会话 —— invoke WS(持久)
|
* - `vipUserSession`(parent):VIP 用户会话 —— invoke WS(持久)
|
||||||
*
|
*
|
||||||
* init 双任务(核心重构):
|
* init 任务:
|
||||||
* - guestSession.initializing:loadQuota + loadHistory 并行(`invoke: [...]` 数组)
|
* - guestSession.initializing:loadHistory
|
||||||
* - nonVipUserSession.initializing:loadHistory
|
* - nonVipUserSession.initializing:loadHistory
|
||||||
* - vipUserSession.initializing:loadHistory + parent-level chatWebSocket 并行
|
* - vipUserSession.initializing:loadHistory + parent-level chatWebSocket 并行
|
||||||
* - 每个任务都是独立 actor,不互相等待(除非 `always` barrier)
|
* - 每个任务都是独立 actor,不互相等待(除非 `always` barrier)
|
||||||
*
|
*
|
||||||
* 消息发送路径(业务事实):
|
* 消息发送路径(业务事实):
|
||||||
* - guestSession:走 HTTP,并先检查本地游客配额
|
* - guestSession:走 HTTP,消息数量限制以后端 `blocked` 响应为准
|
||||||
* - nonVipUserSession:走 HTTP,让后端返回 daily_limit blocked
|
* - nonVipUserSession:走 HTTP,让后端返回 daily_limit blocked
|
||||||
* - vipUserSession + WS 已连:走 WS,不受每日免费次数限制
|
* - vipUserSession + WS 已连:走 WS,不受每日免费次数限制
|
||||||
* - `ChatWebSocketConnected` 事件 → `wsConnected = true`
|
* - `ChatWebSocketConnected` 事件 → `wsConnected = true`
|
||||||
* - `vipUserSession.exit` action → `wsConnected = false`(WS actor cleanup 时也会跑 exit)
|
* - `vipUserSession.exit` action → `wsConnected = false`(WS actor cleanup 时也会跑 exit)
|
||||||
*
|
*
|
||||||
* 配额:
|
* 配额:
|
||||||
* - 游客:保留本地配额 guard,命中后不发送消息
|
* - 前端不再处理本地消息额度;游客 / 注册用户均以后端响应为准。
|
||||||
* - 注册非 VIP:以后端 `blocked=true && blockReason="daily_limit"` 为准
|
* - 后端返回 `blocked=true && blockReason="daily_limit"` 时展示会员引导。
|
||||||
* - `appendUserMessage` 减 `context.wsConnected ? q : Math.max(0, q-1)` —— WS 已连时不递减
|
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { setup, assign } from "xstate";
|
import { setup, assign } from "xstate";
|
||||||
|
|
||||||
import { ChatStorage } from "@/data/storage/chat/chat_storage";
|
|
||||||
import { todayString, Logger } from "@/utils";
|
import { todayString, Logger } from "@/utils";
|
||||||
|
|
||||||
import { ChatState, initialState } from "./chat-state";
|
import { ChatState, initialState } from "./chat-state";
|
||||||
import type { ChatEvent } from "./chat-events";
|
import type { ChatEvent } from "./chat-events";
|
||||||
import { applyHttpSendOutput } from "./chat-machine.helpers";
|
import { applyHttpSendOutput } from "./chat-machine.helpers";
|
||||||
import {
|
import {
|
||||||
loadQuotaActor,
|
|
||||||
loadHistoryActor,
|
loadHistoryActor,
|
||||||
sendMessageHttpActor,
|
sendMessageHttpActor,
|
||||||
sendMessageWsActor,
|
sendMessageWsActor,
|
||||||
loadMoreHistoryActor,
|
loadMoreHistoryActor,
|
||||||
chatWebSocketActor,
|
chatWebSocketActor,
|
||||||
|
unlockPrivateMessageActor,
|
||||||
} from "./chat-machine.actors";
|
} from "./chat-machine.actors";
|
||||||
|
|
||||||
const log = new Logger("StoresChatChatMachine");
|
const log = new Logger("StoresChatChatMachine");
|
||||||
@@ -68,12 +66,12 @@ export const chatMachine = setup({
|
|||||||
events: {} as ChatEvent,
|
events: {} as ChatEvent,
|
||||||
},
|
},
|
||||||
actors: {
|
actors: {
|
||||||
loadQuota: loadQuotaActor,
|
|
||||||
loadHistory: loadHistoryActor,
|
loadHistory: loadHistoryActor,
|
||||||
sendMessageHttp: sendMessageHttpActor,
|
sendMessageHttp: sendMessageHttpActor,
|
||||||
sendMessageWs: sendMessageWsActor,
|
sendMessageWs: sendMessageWsActor,
|
||||||
loadMoreHistory: loadMoreHistoryActor,
|
loadMoreHistory: loadMoreHistoryActor,
|
||||||
chatWebSocket: chatWebSocketActor,
|
chatWebSocket: chatWebSocketActor,
|
||||||
|
unlockPrivateMessage: unlockPrivateMessageActor,
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
startGuestSession: assign(() => ({
|
startGuestSession: assign(() => ({
|
||||||
@@ -91,25 +89,11 @@ export const chatMachine = setup({
|
|||||||
appendGuestUserMessage: assign(({ context, event }) => {
|
appendGuestUserMessage: assign(({ context, event }) => {
|
||||||
if (event.type !== "ChatSendMessage") return {};
|
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();
|
const today = todayString();
|
||||||
ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today);
|
|
||||||
|
|
||||||
ChatStorage.getInstance().setGuestTotalQuota(newTotal);
|
|
||||||
|
|
||||||
log.debug("[chat-machine] appendGuestUserMessage", {
|
log.debug("[chat-machine] appendGuestUserMessage", {
|
||||||
contentLength: event.content.length,
|
contentLength: event.content.length,
|
||||||
contentPreview: event.content.slice(0, 50),
|
contentPreview: event.content.slice(0, 50),
|
||||||
quotaMode: "guest http (decrement)",
|
quotaMode: "server controlled",
|
||||||
oldRemaining: context.guestRemainingQuota,
|
|
||||||
newRemaining,
|
|
||||||
oldTotal: context.guestTotalQuota,
|
|
||||||
newTotal,
|
|
||||||
isReplyingAI: true,
|
isReplyingAI: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,8 +107,6 @@ export const chatMachine = setup({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
isReplyingAI: true,
|
isReplyingAI: true,
|
||||||
guestRemainingQuota: newRemaining,
|
|
||||||
guestTotalQuota: newTotal,
|
|
||||||
paywallTriggered: false,
|
paywallTriggered: false,
|
||||||
paywallReason: null,
|
paywallReason: null,
|
||||||
paywallDetail: null,
|
paywallDetail: null,
|
||||||
@@ -160,22 +142,11 @@ export const chatMachine = setup({
|
|||||||
|
|
||||||
appendGuestUserImage: assign(({ context, event }) => {
|
appendGuestUserImage: assign(({ context, event }) => {
|
||||||
if (event.type !== "ChatSendImage") return {};
|
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", {
|
log.debug("[chat-machine] appendGuestUserImage", {
|
||||||
oldMessagesCount: context.messages.length,
|
oldMessagesCount: context.messages.length,
|
||||||
quotaMode: "guest http (decrement)",
|
quotaMode: "server controlled",
|
||||||
oldRemaining: context.guestRemainingQuota,
|
|
||||||
newRemaining,
|
|
||||||
oldTotal: context.guestTotalQuota,
|
|
||||||
newTotal,
|
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
messages: [
|
messages: [
|
||||||
@@ -188,8 +159,6 @@ export const chatMachine = setup({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
isReplyingAI: true,
|
isReplyingAI: true,
|
||||||
guestRemainingQuota: newRemaining,
|
|
||||||
guestTotalQuota: newTotal,
|
|
||||||
paywallTriggered: false,
|
paywallTriggered: false,
|
||||||
paywallReason: null,
|
paywallReason: null,
|
||||||
paywallDetail: null,
|
paywallDetail: null,
|
||||||
@@ -261,9 +230,112 @@ export const chatMachine = setup({
|
|||||||
return { messages, isReplyingAI: false };
|
return { messages, isReplyingAI: false };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
incrementQuotaExceeded: assign(({ context }) => ({
|
appendAIImage: assign(({ context, event }) => {
|
||||||
quotaExceededTrigger: context.quotaExceededTrigger + 1,
|
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 }),
|
setWsConnected: assign({ wsConnected: true }),
|
||||||
clearWsConnected: assign({ wsConnected: false }),
|
clearWsConnected: assign({ wsConnected: false }),
|
||||||
@@ -308,94 +380,48 @@ export const chatMachine = setup({
|
|||||||
initial: "initializing",
|
initial: "initializing",
|
||||||
states: {
|
states: {
|
||||||
initializing: {
|
initializing: {
|
||||||
// "always" barrier:两个任务都完成才进 ready
|
// 本地游客额度逻辑已停用:只等待历史加载完成。
|
||||||
always: [
|
always: [
|
||||||
{
|
{
|
||||||
target: "ready",
|
target: "ready",
|
||||||
guard: ({ context }) => context.quotaLoaded && context.historyLoaded,
|
guard: ({ context }) => context.historyLoaded,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
invoke: [
|
invoke: {
|
||||||
// 任务 1:加载配额(fromPromise,返结果 + onDone 设 flag)
|
id: "loadHistory",
|
||||||
{
|
src: "loadHistory",
|
||||||
id: "loadQuota",
|
onDone: {
|
||||||
src: "loadQuota",
|
actions: assign(({ event }) => ({
|
||||||
onDone: {
|
messages: event.output.messages,
|
||||||
actions: assign(({ event }) => ({
|
hasMore: event.output.hasMore,
|
||||||
guestRemainingQuota: event.output.remaining,
|
historyOffset: event.output.newOffset,
|
||||||
guestTotalQuota: event.output.total,
|
historyLoaded: true,
|
||||||
quotaLoaded: true,
|
})),
|
||||||
})),
|
|
||||||
},
|
|
||||||
onError: {
|
|
||||||
// 失败也标 loaded,不卡 init(游客可能 quota 服务不可用,让 UI 进 ready)
|
|
||||||
actions: assign({
|
|
||||||
quotaLoaded: true,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
// 任务 2:拉 history(fromPromise,返回最终 network messages)
|
onError: {
|
||||||
{
|
// 失败也标 loaded,不卡 init(让 UI 还是能进 ready,屏幕可能空)
|
||||||
id: "loadHistory",
|
actions: assign({
|
||||||
src: "loadHistory",
|
historyLoaded: true,
|
||||||
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,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
},
|
||||||
},
|
},
|
||||||
ready: {
|
ready: {
|
||||||
on: {
|
on: {
|
||||||
// 配额检查 + 发送消息(3 条 guard,按顺序匹配)
|
ChatSendMessage: {
|
||||||
// 1. 总配额(guestTotalQuota)不足 → 触发 quota exceeded dialog,不发送
|
actions: "appendGuestUserMessage",
|
||||||
// 2. 每日配额(guestRemainingQuota)不足 → 同上
|
guard: ({ event }) => event.content.trim().length > 0,
|
||||||
// 3. 两个配额都 OK + 内容非空 → 发送 + 减配额
|
target: "sending",
|
||||||
ChatSendMessage: [
|
},
|
||||||
// 1. 总配额 不足(guestTotalQuota <= 0) → 触发 quota exceeded dialog
|
ChatSendImage: {
|
||||||
{
|
actions: "appendGuestUserImage",
|
||||||
guard: ({ context }) => context.guestTotalQuota <= 0,
|
target: "sending",
|
||||||
actions: "incrementQuotaExceeded",
|
},
|
||||||
},
|
ChatUnlockPrivateMessage: {
|
||||||
// 2. 每日配额 不足(guestRemainingQuota <= 0) → 触发 quota exceeded dialog
|
actions: "setUnlockingPrivateMessage",
|
||||||
{
|
target: "unlockingPrivate",
|
||||||
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",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
// 删除 ChatLoadMoreHistory / WS handlers —— 游客无服务端 history,也不连接 WS
|
// 删除 ChatLoadMoreHistory / WS handlers —— 游客无服务端 history,也不连接 WS
|
||||||
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
sending: {
|
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: {
|
ChatLoadMoreHistory: {
|
||||||
target: "loadingMore",
|
target: "loadingMore",
|
||||||
},
|
},
|
||||||
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
|
ChatUnlockPrivateMessage: {
|
||||||
|
actions: "setUnlockingPrivateMessage",
|
||||||
|
target: "unlockingPrivate",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
sendingViaHttp: {
|
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",
|
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" },
|
||||||
},
|
},
|
||||||
@@ -615,7 +680,10 @@ export const chatMachine = setup({
|
|||||||
ChatLoadMoreHistory: {
|
ChatLoadMoreHistory: {
|
||||||
target: "loadingMore",
|
target: "loadingMore",
|
||||||
},
|
},
|
||||||
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
|
ChatUnlockPrivateMessage: {
|
||||||
|
actions: "setUnlockingPrivateMessage",
|
||||||
|
target: "unlockingPrivate",
|
||||||
|
},
|
||||||
// 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected
|
// 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected
|
||||||
// 已上提到 vipUserSession.on,子级不再声明
|
// 已上提到 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,23 +18,13 @@ export interface ChatState {
|
|||||||
* - 非 VIP / 游客:固定 false,走 HTTP
|
* - 非 VIP / 游客:固定 false,走 HTTP
|
||||||
*/
|
*/
|
||||||
wsConnected: boolean;
|
wsConnected: boolean;
|
||||||
/** 游客剩余配额(次/天)—— 仅游客业务展示用
|
|
||||||
*/
|
|
||||||
guestRemainingQuota: number;
|
|
||||||
/** 游客总配额(仅游客展示用) */
|
|
||||||
guestTotalQuota: number;
|
|
||||||
quotaExceededTrigger: number;
|
|
||||||
paywallTriggered: boolean;
|
paywallTriggered: boolean;
|
||||||
paywallReason: "daily_limit" | null;
|
paywallReason: "daily_limit" | "photo_paywall" | "private_paywall" | null;
|
||||||
paywallDetail: { usedToday: number; limit: number } | null;
|
paywallDetail: { usedToday: number; limit: number } | null;
|
||||||
|
unlockingPrivateMessageId: string | null;
|
||||||
isLoadingMore: boolean;
|
isLoadingMore: boolean;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
historyOffset: number;
|
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 跑完)
|
/** history 加载完成标志(initial load —— local → network → save 跑完)
|
||||||
* - guestSession.initializing / user sessions initializing 走 always barrier
|
* - guestSession.initializing / user sessions initializing 走 always barrier
|
||||||
* - 不被 `loadMoreHistoryActor` 设置(翻页是另一码事)
|
* - 不被 `loadMoreHistoryActor` 设置(翻页是另一码事)
|
||||||
@@ -46,15 +36,12 @@ export const initialState: ChatState = {
|
|||||||
messages: [],
|
messages: [],
|
||||||
isReplyingAI: false,
|
isReplyingAI: false,
|
||||||
wsConnected: false,
|
wsConnected: false,
|
||||||
guestRemainingQuota: 0,
|
|
||||||
guestTotalQuota: 0,
|
|
||||||
quotaExceededTrigger: 0,
|
|
||||||
paywallTriggered: false,
|
paywallTriggered: false,
|
||||||
paywallReason: null,
|
paywallReason: null,
|
||||||
paywallDetail: null,
|
paywallDetail: null,
|
||||||
|
unlockingPrivateMessageId: null,
|
||||||
isLoadingMore: false,
|
isLoadingMore: false,
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
historyOffset: 0,
|
historyOffset: 0,
|
||||||
quotaLoaded: false,
|
|
||||||
historyLoaded: false,
|
historyLoaded: false,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user