Merge branch 'dev' into test

This commit is contained in:
2026-06-16 15:31:10 +08:00
38 changed files with 362 additions and 242 deletions
+2 -2
View File
@@ -24,8 +24,8 @@ export function AuthScreen() {
const router = useRouter(); const router = useRouter();
// 邮箱登录成功 → 通知 User + 跳转 // 邮箱登录成功 → 通知 User + 跳转
// 用 **pendingRedirect flag**(不是 useRef transition detection)—— // 用 pendingRedirect flag(不是 useRef transition detection)——
// re-mount 时 flag 在 auth context 里**持久**,区分"刚按了"和"re-visit"准确无误。 // re-mount 时 flag 在 auth context 里持久,区分"刚按了"和"re-visit"准确无误。
useEffect(() => { useEffect(() => {
console.log("[auth-screen] useEffect (loginStatus + pendingRedirect)", { console.log("[auth-screen] useEffect (loginStatus + pendingRedirect)", {
pending: state.pendingRedirect, pending: state.pendingRedirect,
+1 -1
View File
@@ -8,7 +8,7 @@
* - 游客模式(isGuest=true):渲染 `GuestBanner` 提示注册(点击 → /auth * - 游客模式(isGuest=true):渲染 `GuestBanner` 提示注册(点击 → /auth
* - 登录模式(isGuest=false):渲染 `MenuButton`(点击 → push 到 /sidebar * - 登录模式(isGuest=false):渲染 `MenuButton`(点击 → push 到 /sidebar
* *
* 注意:菜单按钮直接 push 到侧边栏路由,**不**打开下拉菜单 * 注意:菜单按钮直接 push 到侧边栏路由,打开下拉菜单
* Dart 端 `MenuButton(onTap: _goSidebar)` 行为)。 * Dart 端 `MenuButton(onTap: _goSidebar)` 行为)。
*/ */
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -0,0 +1,50 @@
/* ChatQuotaExhaustedBanner — 游客配额耗尽横幅
* 视觉规格(与设计稿对齐):
* - 粉→品红渐变背景(与 splash gradient 一致)
* - 居中文案 + 渐变 pill 按钮
* - 圆角 + 阴影(与 /sidebar 已有卡片的视觉重量对齐)
*/
.banner {
flex: 0 0 auto;
margin: 0 var(--spacing-lg, 16px) var(--spacing-lg, 16px);
padding: var(--spacing-xl, 24px) var(--spacing-lg, 16px);
border-radius: 24px;
background: linear-gradient(135deg, #f96ade 0%, #f657a0 100%);
text-align: center;
box-shadow: 0 4px 16px rgba(248, 77, 150, 0.25);
}
.text {
color: #fff;
font-size: 1rem;
font-weight: 600;
line-height: 1.4;
margin: 0 0 var(--spacing-lg, 16px);
/* 与 splash 标题的粉色字保持视觉一致(白底渐变叠加层需要白字保证可读性) */
opacity: 0.95;
}
.cta {
width: 100%;
padding: 0.875rem 1.5rem;
border: none;
border-radius: 999px;
background: linear-gradient(135deg, #ffb8e0 0%, #f57ec0 100%);
color: #fff;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
}
.cta:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.cta:active {
transform: scale(0.98);
}
@@ -0,0 +1,73 @@
"use client";
/**
* ChatQuotaExhaustedBanner 游客配额耗尽横幅
*
* 何时显示:
* - isGuest === true(仅游客业务事实"非游客无配额上限")
* - quotaLoaded === true(避免一进 /chat 就跳 banner
* - guestTotalQuota <= 0(用 total 而非 remaining —— 配额刚减为 0 不立即跳)
*
* 视觉规格(与设计稿对齐):
* - 粉→品红渐变背景(与 splash 渐变一致)
* - 大字号粉色/白色提示文案("The limit for free chat times has been reached today"
* - 粉→品红渐变 pill 按钮"Unlock your membership to continue" → 跳 /subscription
*
* 业务关系:
* - 与 `src/app/sidebar/components/vip-benefits-card.tsx` 的 "Activate VIP Membership" 行为一致
* - 与 `src/app/sidebar/components/voice-package-card.tsx` 的 "Buy Package" 行为一致
*
* 不做的事:
* - 不直接管理 state machinechat 机器不感知 UI 层)
* - 不显示具体剩余分钟数(已用 total<=0 表达"耗尽"足够)
*/
import { useRouter } from "next/navigation";
import { ROUTES } from "@/router/routes";
import styles from "./chat-quota-exhausted-banner.module.css";
export interface ChatQuotaExhaustedBannerProps {
/**
* 自定义点击回调(不传则默认 router.push(ROUTES.subscription)
* - 测试时可传 mock
* - 嵌入其他场景时(如 nested overlay)可传自定义
*/
onUnlock?: () => void;
}
export function ChatQuotaExhaustedBanner({
onUnlock,
}: ChatQuotaExhaustedBannerProps) {
const router = useRouter();
const handleClick = () => {
if (onUnlock) {
onUnlock();
return;
}
router.push(ROUTES.subscription);
};
return (
<div
className={styles.banner}
role="status"
aria-live="polite"
data-testid="chat-quota-exhausted-banner"
>
<p className={styles.text}>
The limit for free chat times
<br />
has been reached today
</p>
<button
type="button"
className={styles.cta}
onClick={handleClick}
aria-label="Unlock your membership to continue"
>
Unlock your membership to continue
</button>
</div>
);
}
+33 -17
View File
@@ -2,12 +2,10 @@
/** /**
* ChatScreen 编排层 * ChatScreen 编排层
* *
* 原始 Dart: lib/ui/chat/chat_screen.dart172 行)
*
* 职责(本文件): * 职责(本文件):
* - **订阅 auth 状态机**loginStatus)—— 派生 isGuest / **派鉴权生命周期事件**给 chat 机器 * - 订阅 auth 状态机(loginStatus)—— 派生 isGuest / 派鉴权生命周期事件给 chat 机器
* - 屏幕生命周期事件(init / visible / invisible * - 屏幕生命周期事件(init / visible / invisible
* - **不**直接管理 WebSocket / **不**直接读 AuthStorage(除 token 取值) * - 直接管理 WebSocket / 直接读 AuthStorage(除 token 取值)
* *
* 子组件职责: * 子组件职责:
* - ChatHeader:顶部栏(游客 banner / 菜单按钮) * - ChatHeader:顶部栏(游客 banner / 菜单按钮)
@@ -16,13 +14,13 @@
* - PwaInstallOverlayPWA 安装提示触发器 * - PwaInstallOverlayPWA 安装提示触发器
* - BrowserHintOverlay:浏览器提示 * - BrowserHintOverlay:浏览器提示
* *
* **鉴权解耦****事件驱动**): * 鉴权解耦事件驱动):
* - chat 机器**不**感知鉴权 / **不**管 WebSocket —— 由 chat-screen 派生 loginStatus * - chat 机器感知鉴权 / 管 WebSocket —— 由 chat-screen 派生 loginStatus
* - chat-screen **只**派 3 个事件: * - chat-screen 派 3 个事件:
* - `ChatGuestLogin` → 游客会话(**断** WS = 不连) * - `ChatGuestLogin` → 游客会话( WS = 不连)
* - `ChatNonGuestLogin { token }` → 非游客会话(**连** WS * - `ChatNonGuestLogin { token }` → 非游客会话( WS
* - `ChatLogout` → 登出(机器自动 cleanup WS actor * - `ChatLogout` → 登出(机器自动 cleanup WS actor
* - chat 机器**内部**用 fromCallback actor 完整管理 WS 生命周期 * - chat 机器内部用 fromCallback actor 完整管理 WS 生命周期
*/ */
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import Image from "next/image"; import Image from "next/image";
@@ -38,9 +36,11 @@ import { BrowserHintOverlay } from "./browser-hint-overlay";
import { ChatArea } from "./chat-area"; import { ChatArea } from "./chat-area";
import { ChatHeader } from "./chat-header"; import { ChatHeader } from "./chat-header";
import { ChatInputBar } from "./chat-input-bar"; import { ChatInputBar } from "./chat-input-bar";
import { ChatQuotaExhaustedBanner } from "./chat-quota-exhausted-banner";
import { PwaInstallOverlay } from "./pwa-install-overlay"; import { PwaInstallOverlay } from "./pwa-install-overlay";
import styles from "./chat-screen.module.css"; import styles from "./chat-screen.module.css";
/** /**
* 派生 isGuest —— 单一来源:`auth.loginStatus` * 派生 isGuest —— 单一来源:`auth.loginStatus`
*/ */
@@ -49,16 +49,16 @@ function deriveIsGuest(loginStatus: LoginStatus): boolean {
} }
/** /**
* 按 loginStatus 派鉴权生命周期事件(chat 机器**不**感知 loginStatus —— 这层转换在此) * 按 loginStatus 派鉴权生命周期事件(chat 机器感知 loginStatus —— 这层转换在此)
* *
* 设计:**不**直接管理 WS / **不**读 AuthStorage(除取 token * 设计:直接管理 WS / 读 AuthStorage(除取 token
*/ */
function dispatchAuthLifecycle( function dispatchAuthLifecycle(
loginStatus: LoginStatus, loginStatus: LoginStatus,
chatDispatch: ReturnType<typeof useChatDispatch>, chatDispatch: ReturnType<typeof useChatDispatch>,
): void { ): void {
if (loginStatus === "guest") { if (loginStatus === "guest") {
// 游客:派 ChatGuestLoginchat 机器**不**连 WS —— 业务事实"断 WS" // 游客:派 ChatGuestLoginchat 机器连 WS —— 业务事实"断 WS"
chatDispatch({ type: "ChatGuestLogin" }); chatDispatch({ type: "ChatGuestLogin" });
return; return;
} }
@@ -81,9 +81,20 @@ export function ChatScreen() {
const authState = useAuthState(); const authState = useAuthState();
const chatDispatch = useChatDispatch(); const chatDispatch = useChatDispatch();
// isGuest 派生(chat-screen 是**唯一**知道这个值的地方 —— chat 机器无 isGuest 字段) // isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
const isGuest = deriveIsGuest(authState.loginStatus); const isGuest = deriveIsGuest(authState.loginStatus);
// 游客配额耗尽 → 显示 Unlock CTA banner(替代 ChatInputBar
// 三重条件:游客 + 配额加载完 + 用户被配额 guard 拦截过至少一次
// - quotaLoaded 避免一进 /chat 就跳 bannerloadQuota 还在 in-flight
// - quotaExceededTrigger 而非 guestTotalQuota / guestRemainingQuota ——
// trigger 由 chat 机器的 guard 在 ChatSendMessage / ChatSendImage 被拦截时 +1
// 是 "用户被配额拒绝过" 的权威信号(避免依赖 quota 数值本身的边界判断)
const showExhaustedBanner =
isGuest && state.quotaLoaded && state.quotaExceededTrigger > 0;
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// 屏幕生命周期 + 派初次鉴权生命周期事件 // 屏幕生命周期 + 派初次鉴权生命周期事件
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
@@ -91,7 +102,7 @@ export function ChatScreen() {
dispatchAuthLifecycle(authState.loginStatus, chatDispatch); dispatchAuthLifecycle(authState.loginStatus, chatDispatch);
return () => { return () => {
}; };
// 注:mount useEffect 故意**不**依赖 loginStatus —— 首次 mount 时机是固定的 // 注:mount useEffect 故意依赖 loginStatus —— 首次 mount 时机是固定的
// "用户进 /chat 那一刻"),loginStatus 由 splash/auth-screen 提前 settle。 // "用户进 /chat 那一刻"),loginStatus 由 splash/auth-screen 提前 settle。
// loginStatus 后续变化由下方"副作用 ②" 统一处理。 // loginStatus 后续变化由下方"副作用 ②" 统一处理。
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -110,7 +121,7 @@ export function ChatScreen() {
const prev = prevLoginStatusRef.current; const prev = prevLoginStatusRef.current;
prevLoginStatusRef.current = authState.loginStatus; prevLoginStatusRef.current = authState.loginStatus;
if (prev === null) return; // 首次 mount 由 mount useEffect 处理 if (prev === null) return; // 首次 mount 由 mount useEffect 处理
if (prev === authState.loginStatus) return; // 登录态**没** if (prev === authState.loginStatus) return; // 登录态
dispatchAuthLifecycle(authState.loginStatus, chatDispatch); dispatchAuthLifecycle(authState.loginStatus, chatDispatch);
}, [authState.loginStatus, chatDispatch]); }, [authState.loginStatus, chatDispatch]);
@@ -138,7 +149,12 @@ export function ChatScreen() {
isGuest={isGuest} isGuest={isGuest}
/> />
<ChatInputBar disabled={state.isReplyingAI} /> {showExhaustedBanner ? (
<ChatQuotaExhaustedBanner />
) : (
<ChatInputBar disabled={state.isReplyingAI} />
)}
</div> </div>
{/* 浏览器提示(应用内浏览器检测) */} {/* 浏览器提示(应用内浏览器检测) */}
+2
View File
@@ -8,7 +8,9 @@ export * from "./chat-area";
export * from "./chat-header"; export * from "./chat-header";
export * from "./chat-input-bar"; export * from "./chat-input-bar";
export * from "./chat-input-text-field"; export * from "./chat-input-text-field";
export * from "./chat-quota-exhausted-banner";
export * from "./chat-screen"; export * from "./chat-screen";
export * from "./chat-send-button"; export * from "./chat-send-button";
export * from "./date-header"; export * from "./date-header";
export * from "./fullscreen-image-viewer"; export * from "./fullscreen-image-viewer";
+1 -1
View File
@@ -29,7 +29,7 @@ export function MessageContent({ content, imageUrl, isFromAI }: MessageContentPr
style={{ style={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
// **关键**:在 row flex 父中撑开(不依赖 content 循环)—— 让 TextBubble 能拿到真实宽度 // 关键:在 row flex 父中撑开(不依赖 content 循环)—— 让 TextBubble 能拿到真实宽度
flex: "1 1 0", flex: "1 1 0",
// 防超长 content(图片 / 超长 url)撑爆父 // 防超长 content(图片 / 超长 url)撑爆父
minWidth: 0, minWidth: 0,
@@ -7,7 +7,7 @@
* `redirect` 回调(`app_router.dart:60-78`)。 * `redirect` 回调(`app_router.dart:60-78`)。
* *
* 设计: * 设计:
* - **不**走 Server `redirect()`:避免把 fbid 暴露在 URL 里。 * - 走 Server `redirect()`:避免把 fbid 暴露在 URL 里。
* - deviceId 走 `AuthStorage.getInstance().setDeviceId`(与 `token_interceptor.ts` 同源; * - deviceId 走 `AuthStorage.getInstance().setDeviceId`(与 `token_interceptor.ts` 同源;
* 写入键 `cozsweet.deviceId`)。 * 写入键 `cozsweet.deviceId`)。
* - fbid 走 `window.localStorage.setItem(StorageKeys.facebookId, ...)` * - fbid 走 `window.localStorage.setItem(StorageKeys.facebookId, ...)`
+1 -1
View File
@@ -9,7 +9,7 @@
* Next.js 16 全局 helper `PageProps<'/literal'>` 由 `next dev` / `next build` / * Next.js 16 全局 helper `PageProps<'/literal'>` 由 `next dev` / `next build` /
* `next typegen` 生成。 * `next typegen` 生成。
* *
* 这里**不**调用 `redirect()`:把 fbid 暴露在 URL 上对用户不友好; * 这里调用 `redirect()`:把 fbid 暴露在 URL 上对用户不友好;
* 跳转推迟到 Client 完成持久化之后。 * 跳转推迟到 Client 完成持久化之后。
*/ */
+1 -1
View File
@@ -4,7 +4,7 @@
* Server Component:把访客直接送入 `/splash`,由 splash 页(Client)根据 * Server Component:把访客直接送入 `/splash`,由 splash 页(Client)根据
* `authStorage.hasLoginToken()` 决定是否再跳 `/chat`。 * `authStorage.hasLoginToken()` 决定是否再跳 `/chat`。
* *
* 这里**不**做鉴权重定向:Server 端读不到 localStorage,且当前没有 HttpOnly * 这里做鉴权重定向:Server 端读不到 localStorage,且当前没有 HttpOnly
* cookie(见 `src/data/storage/auth/auth_storage.ts` 的 TODO)。 * cookie(见 `src/data/storage/auth/auth_storage.ts` 的 TODO)。
* `src/proxy.ts` 已为未来 cookie 化预留了基于 `login_token` cookie 的 short-circuit。 * `src/proxy.ts` 已为未来 cookie 化预留了基于 `login_token` cookie 的 short-circuit。
*/ */
@@ -12,6 +12,7 @@
font-family: var(--font-athelas); font-family: var(--font-athelas);
font-size: var(--font-size-xxl); font-size: var(--font-size-xxl);
font-weight: 700; font-weight: 700;
font-style: italic;
color: #ffffff; color: #ffffff;
background: transparent; background: transparent;
border: 0; border: 0;
@@ -43,6 +44,7 @@
color: #ffffff; color: #ffffff;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
font-style: italic;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
+5 -5
View File
@@ -2,14 +2,14 @@
/** /**
* Splash 底部按钮组 * Splash 底部按钮组
* *
* 本组件是 splash 鉴权流程的**纯 UI 入口** * 本组件是 splash 鉴权流程的纯 UI 入口:
* - 消费 `useAuthState` / `useAuthDispatch`(仅用于 isLoading 显示 + 派事件) * - 消费 `useAuthState` / `useAuthDispatch`(仅用于 isLoading 显示 + 派事件)
* - **Skip 按钮 = 显式游客登录入口**(派发 `AuthGuestLoginSubmitted` * - Skip 按钮 = 显式游客登录入口(派发 `AuthGuestLoginSubmitted`
* - 社交登录按钮仅派发 `AuthFacebookLoginSubmitted` 事件,由状态机负责调 NextAuth * - 社交登录按钮仅派发 `AuthFacebookLoginSubmitted` 事件,由状态机负责调 NextAuth
* *
* **派事件策略****单**事件): * 派事件策略(单事件):
* - 派业务事件(`AuthGuestLoginSubmitted` / `AuthFacebookLoginSubmitted`)→ 状态机开始跑 * - 派业务事件(`AuthGuestLoginSubmitted` / `AuthFacebookLoginSubmitted`)→ 状态机开始跑
* - `pendingRedirect: true` 在**登录成功 onDone** 里**自动**置(auth-machine.ts `onLoginSuccess` helper * - `pendingRedirect: true` 在登录成功 onDone 里自动置(auth-machine.ts `onLoginSuccess` helper
* - splash-screen 看到 flag=true + loginStatus 非 notLoggedIn → 跳 /chat * - splash-screen 看到 flag=true + loginStatus 非 notLoggedIn → 跳 /chat
*/ */
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
@@ -35,7 +35,7 @@ export function SplashButton() {
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
{/* Skip 按钮 = 显式游客登录入口(派发 AuthGuestLoginSubmitted**不**做路由跳转) */} {/* Skip 按钮 = 显式游客登录入口(派发 AuthGuestLoginSubmitted做路由跳转) */}
<button <button
type="button" type="button"
onClick={handleSkip} onClick={handleSkip}
+2 -2
View File
@@ -8,7 +8,7 @@ import localFont from "next/font/local";
* subset、inline,浏览器不会向 Google 发送请求,也不会产生 layout shift。 * subset、inline,浏览器不会向 Google 发送请求,也不会产生 layout shift。
* - 产出的 CSS 变量由 `globals.css` 桥接到 Tailwind(如 `font-sans`、`font-athelas`)。 * - 产出的 CSS 变量由 `globals.css` 桥接到 Tailwind(如 `font-sans`、`font-athelas`)。
* *
* 注意:本文件**不**通过 `src/lib/index.ts` 桶导出,避免被误打进客户端 bundle。 * 注意:本文件通过 `src/lib/index.ts` 桶导出,避免被误打进客户端 bundle。
* 引用方式:`import { geistSans, geistMono, athelas } from "@/lib/fonts";` * 引用方式:`import { geistSans, geistMono, athelas } from "@/lib/fonts";`
*/ */
@@ -28,7 +28,7 @@ const geistMono = Geist_Mono({
* 原始资源:/Users/chase/Documents/cozsweet/fonts/Athelas-*.ttf * 原始资源:/Users/chase/Documents/cozsweet/fonts/Athelas-*.ttf
* 放置位置:/public/fonts/Athelas-*.ttf * 放置位置:/public/fonts/Athelas-*.ttf
* *
* 路径计算(`next/font/local` 要求相对路径从**调用文件**出发): * 路径计算(`next/font/local` 要求相对路径从调用文件出发):
* src/lib/fonts.ts → ../../public/fonts/... * src/lib/fonts.ts → ../../public/fonts/...
*/ */
const athelas = localFont({ const athelas = localFont({
@@ -87,7 +87,7 @@ export const onRequestToken: FetchHook = async (ctx) => {
const token = await getAuthToken(); const token = await getAuthToken();
if (token) { if (token) {
// ofetch 推荐:改 ctx.options.headers**不**依赖 ctx.request 是 Request 实例 —— 实例类型不稳定) // ofetch 推荐:改 ctx.options.headers依赖 ctx.request 是 Request 实例 —— 实例类型不稳定)
const headers = new Headers(ctx.options.headers); const headers = new Headers(ctx.options.headers);
headers.set("Authorization", `Bearer ${token}`); headers.set("Authorization", `Bearer ${token}`);
ctx.options.headers = headers; ctx.options.headers = headers;
+4 -4
View File
@@ -6,8 +6,8 @@
* 认证仓库:编排远程 API + 本地 AuthStorage + UserStorage。 * 认证仓库:编排远程 API + 本地 AuthStorage + UserStorage。
* 行为对齐原 Dart `lib/data/repositories/auth_repository_impl.dart` * 行为对齐原 Dart `lib/data/repositories/auth_repository_impl.dart`
* - 登录成功后调 `_saveLoginData` 持久化 token + User * - 登录成功后调 `_saveLoginData` 持久化 token + User
* - 登出先调 API,再**强制**清本地态(无论 API 成败) * - 登出先调 API,再强制清本地态(无论 API 成败)
* - `getCurrentUser` 成功后**尽力**写本地 User 缓存(不因缓存失败影响主流程) * - `getCurrentUser` 成功后尽力写本地 User 缓存(不因缓存失败影响主流程)
* - 写 storage 失败用 `console.warn` 记录,不让本地缓存失败影响主流程 * - 写 storage 失败用 `console.warn` 记录,不让本地缓存失败影响主流程
* *
* 原始 Dart: lib/data/repositories/auth_repository_impl.dart * 原始 Dart: lib/data/repositories/auth_repository_impl.dart
@@ -98,7 +98,7 @@ export class AuthRepository implements IAuthRepository {
} }
/** /**
* 退出登录:先调 API 注销服务端会话,**无论成败**都清本地登录态。 * 退出登录:先调 API 注销服务端会话,无论成败都清本地登录态。
* 强制清本地的逻辑:用户即使在 API 失败时也想从设备上登出。 * 强制清本地的逻辑:用户即使在 API 失败时也想从设备上登出。
*/ */
async logout(): Promise<Result<void>> { async logout(): Promise<Result<void>> {
@@ -237,7 +237,7 @@ export class AuthRepository implements IAuthRepository {
} }
/** /**
* 获取当前登录用户。成功后**尽力**写本地 User 缓存(offline hydrate), * 获取当前登录用户。成功后尽力写本地 User 缓存(offline hydrate),
* 缓存失败用 `console.warn` 记录,不影响主流程。 * 缓存失败用 `console.warn` 记录,不影响主流程。
*/ */
async getCurrentUser(): Promise<Result<User>> { async getCurrentUser(): Promise<Result<User>> {
@@ -40,7 +40,7 @@ export interface IAuthRepository {
sendCode(email: string): Promise<Result<void>>; sendCode(email: string): Promise<Result<void>>;
/** /**
* 退出登录:先调 API 注销服务端会话,**无论成败**都清本地登录态。 * 退出登录:先调 API 注销服务端会话,无论成败都清本地登录态。
*/ */
logout(): Promise<Result<void>>; logout(): Promise<Result<void>>;
+1 -1
View File
@@ -15,7 +15,7 @@ export const ChatSendResponseSchema = z.object({
currentMood: z.string().default(""), currentMood: z.string().default(""),
messageId: z.string(), messageId: z.string(),
isGuest: z.boolean().default(false), isGuest: z.boolean().default(false),
// 函数式 default —— 每次 parse 时**重新**调 Date.now()(后端不返回 timestamp 时用**当下**时间) // 函数式 default —— 每次 parse 时重新调 Date.now()(后端不返回 timestamp 时用当下时间)
timestamp: z.number().default(() => Date.now()), timestamp: z.number().default(() => Date.now()),
}); });
+3 -17
View File
@@ -1,19 +1,5 @@
"use client"; "use client";
/**
* ChatStorage 完整实现(基于 unstorage 抽象)
*
* 对齐 Dart 端 `ChatStorage`lib/data/services/storage/chat/chat_storage.dart):
* - 游客每日聊天配额(GuestChatQuota{ remaining, date }
* - 游客总配额(int
*
* 注意:本类与 `LocalChatStorage`IndexedDB / Dexie)是不同类:
* - ChatStorage = 游客配额等元数据(KV)
* - LocalChatStorage = 聊天记录本身(IndexedDB
*
* 设计:内部使用 SpAsyncUtil 静态类 + Zod 校验;保持对外 API 完全兼容。
*/
import { import {
GuestChatQuotaSchema, GuestChatQuotaSchema,
type GuestChatQuotaData, type GuestChatQuotaData,
@@ -43,7 +29,7 @@ export class ChatStorage implements IChatStorage {
// ---- guest daily chat quota ---- // ---- guest daily chat quota ----
getGuestDailyChatQuota(): Promise<ResultT<GuestChatQuotaData | null>> { getGuestDailyChatQuota(): Promise<ResultT<GuestChatQuotaData | null>> {
// get-or-init**有**返**有****无**则**用** DTO 默认**值**`threshold` = max per day**初**始**化**并**返**回** // get-or-init有返有,无则用 DTO 默认`threshold` = max per day初始化并返回
return SpAsyncUtil.getJson( return SpAsyncUtil.getJson(
StorageKeys.guestChatQuota, StorageKeys.guestChatQuota,
GuestChatQuotaSchema, GuestChatQuotaSchema,
@@ -57,7 +43,7 @@ export class ChatStorage implements IChatStorage {
today, today,
); );
if (Result.isErr(initResult)) { if (Result.isErr(initResult)) {
return existing; // **初**始**化**失**败** → **返**原**始** null return existing; // 初始化失败 → 返原始 null
} }
return { return {
success: true, success: true,
@@ -78,7 +64,7 @@ export class ChatStorage implements IChatStorage {
// ---- guest total quota ---- // ---- guest total quota ----
getGuestTotalQuota(): Promise<ResultT<number | null>> { getGuestTotalQuota(): Promise<ResultT<number | null>> {
// get-or-init**有**返**有****无**则**用** DTO 默认**值**`totalQuotaDefault`**初**始**化**并**返**回** // get-or-init有返有,无则用 DTO 默认`totalQuotaDefault`初始化并返回
return SpAsyncUtil.getJson( return SpAsyncUtil.getJson(
StorageKeys.guestTotalQuotaRemaining, StorageKeys.guestTotalQuotaRemaining,
z.number(), z.number(),
+1 -1
View File
@@ -24,7 +24,7 @@ export const handler = NextAuth({
// 把 OAuth provider 的 token 塞进 NextAuth session,供前端取用: // 把 OAuth provider 的 token 塞进 NextAuth session,供前端取用:
// - Google: account.id_token → 调后端 /api/auth/google-login 换业务 token // - Google: account.id_token → 调后端 /api/auth/google-login 换业务 token
// - Facebook: account.access_token → 调后端 /api/auth/facebook-login // - Facebook: account.access_token → 调后端 /api/auth/facebook-login
// 仅在**首次 sign-in** 时把 `account` 注入到 JWT;后续请求 account=undefined // 仅在首次 sign-in 时把 `account` 注入到 JWT;后续请求 account=undefined
// (由 NextAuth 控制),所以下面的 `if (account)` 守卫是必要的。 // (由 NextAuth 控制),所以下面的 `if (account)` 守卫是必要的。
callbacks: { callbacks: {
async jwt({ token, account }) { async jwt({ token, account }) {
+2 -2
View File
@@ -2,7 +2,7 @@
/** /**
* AuthPlatform 统一 OAuth 登录入口 * AuthPlatform 统一 OAuth 登录入口
* *
* **两个**独立方法(**不**再依赖构造器): * 两个独立方法(不再依赖构造器):
* - `googleSignIn()` → 触发 Google OAuth * - `googleSignIn()` → 触发 Google OAuth
* - `facebookSignIn()` → 触发 Facebook OAuth * - `facebookSignIn()` → 触发 Facebook OAuth
* *
@@ -12,7 +12,7 @@
* 3. NextAuth 写 next-auth.session-token (httpOnly) cookie * 3. NextAuth 写 next-auth.session-token (httpOnly) cookie
* 4. 重定向到 callbackUrl (默认 /chat) * 4. 重定向到 callbackUrl (默认 /chat)
* *
* **不**做任何持久化(不写 unstorage / 不调后端 / 不设自定义 cookie)。 * 做任何持久化(不写 unstorage / 不调后端 / 不设自定义 cookie)。
* 原始 Dart: nextauth-helpers.{googleLogin, facebookLogin}() * 原始 Dart: nextauth-helpers.{googleLogin, facebookLogin}()
*/ */
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
+1 -1
View File
@@ -3,7 +3,7 @@
* 登出(NextAuth 流程触发器) * 登出(NextAuth 流程触发器)
* *
* 调用 `signOut()` 清除 NextAuth session cookie 并重定向。 * 调用 `signOut()` 清除 NextAuth session cookie 并重定向。
* **不**做任何持久化(不写 unstorage / 不调后端 / 不设自定义 cookie)。 * 做任何持久化(不写 unstorage / 不调后端 / 不设自定义 cookie)。
* 原始 Dart: nextauth-helpers.logout() * 原始 Dart: nextauth-helpers.logout()
*/ */
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
+1 -1
View File
@@ -3,7 +3,7 @@
* SessionProvider 客户端包装 * SessionProvider 客户端包装
* *
* 背景:next-auth v4 的 `SessionProvider`(从 `next-auth/react` 导出) * 背景:next-auth v4 的 `SessionProvider`(从 `next-auth/react` 导出)
* **没有** `"use client"` 指令。这是 v4 的历史遗留(v5 / Auth.js 已修正)。 * 没有 `"use client"` 指令。这是 v4 的历史遗留(v5 / Auth.js 已修正)。
* *
* 当 Server Component(如 `src/app/layout.tsx`)直接 import 时,Next.js * 当 Server Component(如 `src/app/layout.tsx`)直接 import 时,Next.js
* 不会自动把它当 client component 渲染,会继续在服务端 tree 里执行,导致 * 不会自动把它当 client component 渲染,会继续在服务端 tree 里执行,导致
+1 -1
View File
@@ -1,4 +1,4 @@
// /** // /
// * Router 公共导出 // * Router 公共导出
// * // *
// * 集中管理: // * 集中管理:
+8 -8
View File
@@ -1,4 +1,4 @@
// /** // /
// * Next.js 16 Proxy(原 `middleware.ts` // * Next.js 16 Proxy(原 `middleware.ts`
// * // *
// * 行为(鉴权路由统一在此处理,业务层组件不再做 auth-driven 跳转): // * 行为(鉴权路由统一在此处理,业务层组件不再做 auth-driven 跳转):
@@ -9,17 +9,17 @@
// * - 其他情况透传(`NextResponse.next()` // * - 其他情况透传(`NextResponse.next()`
// * // *
// * 重要约束: // * 重要约束:
// * - **不**做自定义 cookie 持久化:统一用 NextAuth 内置 `next-auth.session-token` / // * - 做自定义 cookie 持久化:统一用 NextAuth 内置 `next-auth.session-token` /
// * `__Secure-next-auth.session-token`v5 默认 httpOnly + secure)作信号。 // * `__Secure-next-auth.session-token`v5 默认 httpOnly + secure)作信号。
// * - 真正的鉴权门在 Client 侧 `useSession()`next-auth/react),用于 UI 状态。 // * - 真正的鉴权门在 Client 侧 `useSession()`next-auth/react),用于 UI 状态。
// * - 路由跳转(**唯一**鉴权跳转点)由本 proxy 集中处理。 // * - 路由跳转(唯一鉴权跳转点)由本 proxy 集中处理。
// * - 副作用:邮箱登录用户(不走 NextAuth)不会被本 proxy 识别为已登录。 // * - 副作用:邮箱登录用户(不走 NextAuth)不会被本 proxy 识别为已登录。
// * 邮箱登录在后续轮次若要恢复 proxy 行为,需引入 NextAuth EmailProvider 或独立 // * 邮箱登录在后续轮次若要恢复 proxy 行为,需引入 NextAuth EmailProvider 或独立
// * email-session 体系。 // * email-session 体系。
// * // *
// * Next 16 重要变更(参见 `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md`): // * Next 16 重要变更(参见 `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md`):
// * - 文件从 `middleware.ts` 重命名为 `proxy.ts``middleware` 仍可用但已 deprecated。 // * - 文件从 `middleware.ts` 重命名为 `proxy.ts``middleware` 仍可用但已 deprecated。
// * - 默认 Node.js runtime**不**支持 `runtime` 配置(设置会抛错)。 // * - 默认 Node.js runtime支持 `runtime` 配置(设置会抛错)。
// * - 函数导出名建议为 `proxy`(可默认导出)。 // * - 函数导出名建议为 `proxy`(可默认导出)。
// * // *
// * 用 `src/` 时 proxy 必须放在 `src/` 内(参见 `src-folder.md`)。 // * 用 `src/` 时 proxy 必须放在 `src/` 内(参见 `src-folder.md`)。
@@ -36,13 +36,13 @@
// ROUTES, // ROUTES,
// } from "@/router/routes"; // } from "@/router/routes";
// /** NextAuth 内置 session cookie 名(v5 默认 httpOnly */ // / NextAuth 内置 session cookie 名(v5 默认 httpOnly */
// const SESSION_COOKIE_NAMES = [ // const SESSION_COOKIE_NAMES = [
// "next-auth.session-token", // "next-auth.session-token",
// "__Secure-next-auth.session-token", // "__Secure-next-auth.session-token",
// ]; // ];
// /** proxy 入口日志(Node.js runtime 跑,pino 兼容)—— 排查 / 监控路由用 */ // / proxy 入口日志(Node.js runtime 跑,pino 兼容)—— 排查 / 监控路由用 */
// const log = new Logger("Proxy"); // const log = new Logger("Proxy");
// function isAuthOnlyRoute(pathname: string): boolean { // function isAuthOnlyRoute(pathname: string): boolean {
@@ -53,7 +53,7 @@
// return (PROTECTED_ROUTES as readonly string[]).includes(pathname); // return (PROTECTED_ROUTES as readonly string[]).includes(pathname);
// } // }
// /** // /
// * Proxy 入口 // * Proxy 入口
// */ // */
// export function proxy(request: NextRequest) { // export function proxy(request: NextRequest) {
@@ -89,7 +89,7 @@
// return NextResponse.next(); // return NextResponse.next();
// } // }
// /** // /
// * 匹配器:排除 API、Next 静态资源、图标与常见静态文件扩展名。 // * 匹配器:排除 API、Next 静态资源、图标与常见静态文件扩展名。
// * 不排除 `_next/data`,因为 proxy 文档明确说明"即使在排除列表中 proxy 仍会跑" // * 不排除 `_next/data`,因为 proxy 文档明确说明"即使在排除列表中 proxy 仍会跑"
// * (安全兜底)。 // * (安全兜底)。
+2 -2
View File
@@ -9,9 +9,9 @@
* - 不导出 Client-only 模块(`use-auth-gate` 走独立路径导入),保证 Server bundle 不被污染。 * - 不导出 Client-only 模块(`use-auth-gate` 走独立路径导入),保证 Server bundle 不被污染。
* - 动态深链通过 `ROUTE_BUILDERS` 函数生成,避免手拼字符串。 * - 动态深链通过 `ROUTE_BUILDERS` 函数生成,避免手拼字符串。
* *
* 注意:proxy 路由跳转逻辑(2026-06-15 迁出),但本文件的**类型化常量**仍在 * 注意:proxy 路由跳转逻辑(2026-06-15 迁出),但本文件的类型化常量仍在
* 被 8 个组件(auth-screen / chat-header / DeepLinkPersist / * 被 8 个组件(auth-screen / chat-header / DeepLinkPersist /
* error / not-found / page / sidebar-screen / splash-screen)使用 —— **不能**整体注释。 * error / not-found / page / sidebar-screen / splash-screen)使用 —— 不能整体注释。
*/ */
/** 静态路由字面量 */ /** 静态路由字面量 */
+6 -6
View File
@@ -93,7 +93,7 @@ export const syncFacebookBackendActor = fromPromise<
}); });
// ========== 显式游客登录 actoropt-in ========== // ========== 显式游客登录 actoropt-in ==========
// 由 splash UI 派发 `AuthGuestLoginSubmitted` 触发,**不**自动跑。 // 由 splash UI 派发 `AuthGuestLoginSubmitted` 触发,自动跑。
// 取代了之前 checkAuthStatusActor 的 auto-guest-login 行为 —— 避免每次 // 取代了之前 checkAuthStatusActor 的 auto-guest-login 行为 —— 避免每次
// 访 splash 都自动创建游客账号污染后端。 // 访 splash 都自动创建游客账号污染后端。
// //
@@ -115,7 +115,7 @@ export const guestLoginActor = fromPromise<LoginStatus, void>(async () => {
console.log("[auth-machine] guestLoginActor SHORT-CIRCUIT (has local guestToken) → return guest"); console.log("[auth-machine] guestLoginActor SHORT-CIRCUIT (has local guestToken) → return guest");
return "guest" as LoginStatus; return "guest" as LoginStatus;
} }
// Result.err(_) 时**不**抛 —— 落到 API 路径让真正的错误信号出现 // Result.err(_) 时抛 —— 落到 API 路径让真正的错误信号出现
// 2. 没有(或读不出)才走完整流程:拿 deviceId → 调后端 // 2. 没有(或读不出)才走完整流程:拿 deviceId → 调后端
const deviceId = await deviceIdentifier.getDeviceId(); const deviceId = await deviceIdentifier.getDeviceId();
@@ -138,15 +138,15 @@ export const guestLoginActor = fromPromise<LoginStatus, void>(async () => {
// ========== 启动 / 恢复时检查登录态 actor(只查不创) ========== // ========== 启动 / 恢复时检查登录态 actor(只查不创) ==========
// 由 <AuthStatusChecker /> 在 mount 时派发(一次性)。 // 由 <AuthStatusChecker /> 在 mount 时派发(一次性)。
// 流程(**纯只读**): // 流程(纯只读):
// 1. deviceIdentifier.getDeviceId() —— 无则通过 fingerprintjs 生成 + 落盘 // 1. deviceIdentifier.getDeviceId() —— 无则通过 fingerprintjs 生成 + 落盘
// (给后续 OAuth/email 登录准备 deviceId**不**在 splash 首次访问就 auto-create 游客) // (给后续 OAuth/email 登录准备 deviceId在 splash 首次访问就 auto-create 游客)
// 2. AuthStorage.getLoginToken() —— 有 → "email" // 2. AuthStorage.getLoginToken() —— 有 → "email"
// 3. AuthStorage.getGuestToken() —— 有 → "guest" // 3. AuthStorage.getGuestToken() —— 有 → "guest"
// 4. 都没有 → "notLoggedIn"(让用户**显式**选 OAuth / Email / Guest 入口) // 4. 都没有 → "notLoggedIn"(让用户显式选 OAuth / Email / Guest 入口)
export const checkAuthStatusActor = fromPromise<LoginStatus, void>(async () => { export const checkAuthStatusActor = fromPromise<LoginStatus, void>(async () => {
// 1. 拿 deviceId**不**用于 auto-guest-login,仅供后续登录用) // 1. 拿 deviceId用于 auto-guest-login,仅供后续登录用)
await deviceIdentifier.getDeviceId(); await deviceIdentifier.getDeviceId();
const storage = AuthStorage.getInstance(); const storage = AuthStorage.getInstance();
+5 -5
View File
@@ -13,9 +13,9 @@ export type AuthEvent =
| { type: "AuthModeChanged"; mode: AuthMode } | { type: "AuthModeChanged"; mode: AuthMode }
| { type: "AuthFormCleared" } | { type: "AuthFormCleared" }
| { type: "AuthReset" } | { type: "AuthReset" }
/** 启动 / 恢复时检查登录态 —— 拿 deviceId → 查 token**不**自动创建游客账号) */ /** 启动 / 恢复时检查登录态 —— 拿 deviceId → 查 token自动创建游客账号) */
| { type: "AuthStatusCheckSubmitted" } | { type: "AuthStatusCheckSubmitted" }
/** 显式游客登录 —— splash 上点"游客模式"按钮触发(**不**自动) */ /** 显式游客登录 —— splash 上点"游客模式"按钮触发(自动) */
| { type: "AuthGuestLoginSubmitted" } | { type: "AuthGuestLoginSubmitted" }
// 业务事件(提交) // 业务事件(提交)
| { type: "AuthEmailLoginSubmitted"; email: string; password: string } | { type: "AuthEmailLoginSubmitted"; email: string; password: string }
@@ -35,7 +35,7 @@ export type AuthEvent =
| { type: "AuthGoogleSyncSubmitted"; idToken: string } | { type: "AuthGoogleSyncSubmitted"; idToken: string }
| { type: "AuthFacebookSyncSubmitted"; accessToken: string } | { type: "AuthFacebookSyncSubmitted"; accessToken: string }
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
// splash / auth screen 跳完 /chat 后**清**"刚按了"信号(一次性): // splash / auth screen 跳完 /chat 后"刚按了"信号(一次性):
// `pendingRedirect` 在 login onDone action 里**自动**置 true(不是按钮派的) // `pendingRedirect` 在 login onDone action 里自动置 true(不是按钮派的)
// —— 因此**只**需要 `AuthClearPendingRedirect` 一个事件 // —— 因此需要 `AuthClearPendingRedirect` 一个事件
| { type: "AuthClearPendingRedirect" }; | { type: "AuthClearPendingRedirect" };
+8 -8
View File
@@ -27,13 +27,13 @@ export type { AuthEvent } from "./auth-events";
// //
// 设计:所有登录/同步流跑完都回 `idle`(带正确 `loginStatus`)。 // 设计:所有登录/同步流跑完都回 `idle`(带正确 `loginStatus`)。
// //
// `pendingRedirect: boolean`"用户**显式**触发了登录"信号)—— // `pendingRedirect: boolean`"用户显式触发了登录"信号)——
// **在登录成功的 onDone 里自动置 true** // 在登录成功的 onDone 里自动置 true
// splash / auth screen 看到 `pendingRedirect && loginStatus !== "notLoggedIn"` 跳 /chat // splash / auth screen 看到 `pendingRedirect && loginStatus !== "notLoggedIn"` 跳 /chat
// 跳完派 `AuthClearPendingRedirect` 置 false // 跳完派 `AuthClearPendingRedirect` 置 false
// checkingAuthStatus **不**设 flag —— 被动查 token 不算"用户意图"。 // checkingAuthStatus 设 flag —— 被动查 token 不算"用户意图"。
// //
// 注:onDone 用了**内联**assign(不是 helper function)—— XState v5 的 TypeScript // 注:onDone 用了内联assign(不是 helper function)—— XState v5 的 TypeScript
// 推断对 helper return type 不友好;内联能保留 `state.matches("loadingXxx")` 的类型。 // 推断对 helper return type 不友好;内联能保留 `state.matches("loadingXxx")` 的类型。
// ============================================================ // ============================================================
export const authMachine = setup({ export const authMachine = setup({
@@ -83,7 +83,7 @@ export const authMachine = setup({
}, },
// 启动 / 恢复时检查登录态 —— 由 <AuthStatusChecker /> 派发 // 启动 / 恢复时检查登录态 —— 由 <AuthStatusChecker /> 派发
AuthStatusCheckSubmitted: "checkingAuthStatus", AuthStatusCheckSubmitted: "checkingAuthStatus",
// 显式游客登录 —— 由 splash UI 派发(**不**自动) // 显式游客登录 —— 由 splash UI 派发(自动)
AuthGuestLoginSubmitted: "loadingGuestLogin", AuthGuestLoginSubmitted: "loadingGuestLogin",
AuthEmailLoginSubmitted: "loadingEmailLogin", AuthEmailLoginSubmitted: "loadingEmailLogin",
AuthEmailRegisterSubmitted: "loadingEmailRegister", AuthEmailRegisterSubmitted: "loadingEmailRegister",
@@ -98,7 +98,7 @@ export const authMachine = setup({
errorMessage: "Apple login not implemented", errorMessage: "Apple login not implemented",
}), }),
}, },
// splash / auth screen 跳完 /chat 后**清**flag // splash / auth screen 跳完 /chat 后flag
AuthClearPendingRedirect: { AuthClearPendingRedirect: {
actions: assign({ pendingRedirect: false }), actions: assign({ pendingRedirect: false }),
}, },
@@ -110,10 +110,10 @@ export const authMachine = setup({
invoke: { invoke: {
src: "checkAuthStatus", src: "checkAuthStatus",
onDone: { onDone: {
target: "idle", // ← status check 完回 idle**不**再假冒 success target: "idle", // ← status check 完回 idle再假冒 success
actions: assign({ actions: assign({
loginStatus: ({ event }) => event.output, loginStatus: ({ event }) => event.output,
// **不**置 pendingRedirect —— 状态查询是**被动**读 token,不算"用户意图" // 置 pendingRedirect —— 状态查询是被动读 token,不算"用户意图"
errorMessage: null, errorMessage: null,
}), }),
}, },
+3 -3
View File
@@ -18,12 +18,12 @@ export interface AuthState {
/** 当前登录状态(未登录 / 游客 / OAuth / 邮箱) */ /** 当前登录状态(未登录 / 游客 / OAuth / 邮箱) */
loginStatus: LoginStatus; loginStatus: LoginStatus;
/** /**
* **一次性的"刚按了"信号** —— Skip / Facebook / 邮箱登录按钮**刚**派事件 → 置 true * 一次性的"刚按了"信号 —— Skip / Facebook / 邮箱登录按钮派事件 → 置 true
* → splash-screen / auth-screen 看到 `pendingRedirect && loginStatus !== "notLoggedIn"` 触发跳转 * → splash-screen / auth-screen 看到 `pendingRedirect && loginStatus !== "notLoggedIn"` 触发跳转
* → 跳完置 false * → 跳完置 false
* *
* 历史:之前用 useRef 做 transition detection**但** re-mount 时 ref 重置,bug 复发。 * 历史:之前用 useRef 做 transition detection re-mount 时 ref 重置,bug 复发。
* 现**在 auth state 里持久**machine 不重置)—— re-mount 时也能区分"刚按"和"历史"。 * 现在 auth state 里持久(machine 不重置)—— re-mount 时也能区分"刚按"和"历史"。
*/ */
pendingRedirect: boolean; pendingRedirect: boolean;
} }
+1 -1
View File
@@ -9,7 +9,7 @@
* → auth machine 调 `authRepository.googleLogin/facebookLogin` 换业务 token * → auth machine 调 `authRepository.googleLogin/facebookLogin` 换业务 token
* → 业务 token 写入本地 storage * → 业务 token 写入本地 storage
* *
* 此组件**无可见 UI**(返回 null),仅作为 NextAuth session 与 auth state * 此组件无可见 UI(返回 null),仅作为 NextAuth session 与 auth state
* machine 之间的桥接器。挂在 RootProviders 内、AuthProvider 之后: * machine 之间的桥接器。挂在 RootProviders 内、AuthProvider 之后:
* <AuthProvider> ← 提供 useAuthState / useAuthDispatch * <AuthProvider> ← 提供 useAuthState / useAuthDispatch
* <OAuthSessionSync /> ← 用上面两个 hook * <OAuthSessionSync /> ← 用上面两个 hook
+4 -15
View File
@@ -1,16 +1,5 @@
"use client"; "use client";
/**
* ChatContext:基于 XState v5 的 React Context Provider
*
* 业务事件通过 `useMachine(chatMachine).send()` 派发。
* XState 自动处理 HTTP 副作用(invoke + fromPromise actors)。
*
* 兼容性:保持原 `useChatState()` / `useChatDispatch()` API。
* 内部将 XState 状态机快照映射回原 `ChatState` 形状。
*
* 注:WebSocket 长生命周期 actor **由 chat-screen.tsx 监听 auth 状态机驱动**——
* 鉴权解耦(loginStatus 在 authchat 机器**不**感知鉴权)。
*/
import { import {
type Dispatch, type Dispatch,
type ReactNode, type ReactNode,
@@ -26,7 +15,7 @@ import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
/** /**
* 对外暴露的 State 形状 * 对外暴露的 State 形状
* *
* **isGuest 字段已删** —— 由 chat-screen 派生自 `auth.loginStatus === "guest"`。 * isGuest 字段已删 —— 由 chat-screen 派生自 `auth.loginStatus === "guest"`。
* 消费方(chat-screen)通过 `useAuthState()` 取 loginStatus,本地派生 isGuest。 * 消费方(chat-screen)通过 `useAuthState()` 取 loginStatus,本地派生 isGuest。
*/ */
interface ChatState { interface ChatState {
@@ -40,9 +29,9 @@ interface ChatState {
isLoadingMore: boolean; isLoadingMore: boolean;
hasMore: boolean; hasMore: boolean;
historyOffset: number; historyOffset: number;
/** 游客配额加载完成标**志****仅** guestSession 期间) —— UI 可**用**此**显**示 loading */ /** 游客配额加载完成标志(仅 guestSession 期间) —— UI 可用此显示 loading */
quotaLoaded: boolean; quotaLoaded: boolean;
/** history 初始加**载**完成标**志**local → network → save 跑**完** —— UI 可**用**此**显**示 loading */ /** history 初始加载完成标志local → network → save 跑 —— UI 可用此显示 loading */
historyLoaded: boolean; historyLoaded: boolean;
} }
+9 -9
View File
@@ -3,20 +3,20 @@
* *
* 事件名与原 Dart ChatEvent 对齐。 * 事件名与原 Dart ChatEvent 对齐。
* *
* 历史:原本有 `ChatAuthStatusChanged`chat 机器刷新 isGuest)—— 已**删** * 历史:原本有 `ChatAuthStatusChanged`chat 机器刷新 isGuest)—— 已
* 鉴权状态变化(loginStatus)由 chat-screen 通过 `useAuthState()` 订阅, * 鉴权状态变化(loginStatus)由 chat-screen 通过 `useAuthState()` 订阅,
* chat 机器**不**感知鉴权。 * chat 机器感知鉴权。
* *
* 鉴权生命周期事件(**本轮新增**): * 鉴权生命周期事件(本轮新增):
* - `ChatGuestLogin`:游客进入 /chat —— 拉本地消息 + 初始化配额(**不**连 WS * - `ChatGuestLogin`:游客进入 /chat —— 拉本地消息 + 初始化配额(连 WS
* - `ChatNonGuestLogin`:非游客进入 /chat —— 拉服务器端首屏 + **连** WStoken 在 payload * - `ChatNonGuestLogin`:非游客进入 /chat —— 拉服务器端首屏 + WStoken 在 payload
* - `ChatLogout`:登出 / 切换用户 —— 断 WS + 清消息 * - `ChatLogout`:登出 / 切换用户 —— 断 WS + 清消息
* *
* 设计:**所有**WS / 历史 / 配额相关副作用**全部**通过派发事件给 chat 机器处理, * 设计:所有WS / 历史 / 配额相关副作用全部通过派发事件给 chat 机器处理,
* chat-screen **不**直接创建 ChatWebSocket / 不读 AuthStorage(除 token 取值)。 * chat-screen 直接创建 ChatWebSocket / 不读 AuthStorage(除 token 取值)。
*/ */
export type ChatEvent = export type ChatEvent =
// 鉴权生命周期(**用户**显式触发登录后由 chat-screen 派) // 鉴权生命周期(用户显式触发登录后由 chat-screen 派)
| { type: "ChatGuestLogin" } | { type: "ChatGuestLogin" }
| { type: "ChatNonGuestLogin"; token: string } | { type: "ChatNonGuestLogin"; token: string }
| { type: "ChatLogout" } | { type: "ChatLogout" }
@@ -24,7 +24,7 @@ export type ChatEvent =
| { type: "ChatSendMessage"; content: string } | { type: "ChatSendMessage"; content: string }
| { type: "ChatSendImage"; imageBase64: string } | { type: "ChatSendImage"; imageBase64: string }
| { type: "ChatLoadMoreHistory" } | { type: "ChatLoadMoreHistory" }
// WebSocket / AI 推句(**由** chat 机器内部 actor 派回 —— "机器**自**驱动" // WebSocket / AI 推句( chat 机器内部 actor 派回 —— "机器驱动"
| { | {
type: "ChatAISentenceReceived"; type: "ChatAISentenceReceived";
index: number; index: number;
+23 -23
View File
@@ -2,16 +2,16 @@
* Chat 状态机:4 个 XState actor * Chat 状态机:4 个 XState actor
* *
* 从 `chat-machine.ts` 抽出的"Actors"段: * 从 `chat-machine.ts` 抽出的"Actors"段:
* - `loadQuotaActor`fromPromise**仅**游客有意义 —— 拉**日**配 + 总**配** * - `loadQuotaActor`fromPromise游客有意义 —— 拉日配 + 总配
* - `loadHistoryActor`fromPromise —— local → network → save network to local 3 **步** * - `loadHistoryActor`fromPromise —— local → network → save network to local 3
* - `sendMessageHttpActor`fromPromise**已** wire —— "一次发送 = 一次返回" * - `sendMessageHttpActor`fromPromise wire —— "一次发送 = 一次返回"
* - `loadMoreHistoryActor`fromPromise —— 翻**页****不**走 local-first * - `loadMoreHistoryActor`fromPromise —— 翻页,不走 local-first
* - `chatWebSocketActor`fromCallback,长生命周期 —— **仅** userSession 期间 invoke * - `chatWebSocketActor`fromCallback,长生命周期 —— userSession 期间 invoke
* *
* 设计: * 设计:
* - 依赖 `chat-machine.helpers.ts`(纯函数 + 数据加载) * - 依赖 `chat-machine.helpers.ts`(纯函数 + 数据加载)
* - 依赖 `createChatWebSocket``@/core/net/chat-websocket` * - 依赖 `createChatWebSocket``@/core/net/chat-websocket`
* - 保留**全链日志**(用户要求"不要将其删除") * - 保留全链日志(用户要求"不要将其删除")
* - 命名约定:`[chat-machine]` 前缀 —— 与 `chat-machine.ts` 的日志对齐 * - 命名约定:`[chat-machine]` 前缀 —— 与 `chat-machine.ts` 的日志对齐
*/ */
@@ -31,12 +31,12 @@ import {
import type { ChatEvent } from "./chat-events"; import type { ChatEvent } from "./chat-events";
// ============================================================ // ============================================================
// Init 任务 1:拉游客配额(fromPromise**纯** local // Init 任务 1:拉游客配额(fromPromise local
// ============================================================ // ============================================================
/** /**
* 拉游客**日**配 + 总**配****仅** guestSession 期间 invoke * 拉游客日配 + 总配(仅 guestSession 期间 invoke
* - 失**败**返 `{ remaining: 0, total: 0 }`**不**抛,**让** UI **还**是**能**进** ready * - 失返 `{ remaining: 0, total: 0 }`不抛,让 UI 还是能进 ready
* - onDone 在 state machine **里**赋 guestRemainingQuota + guestTotalQuota + 设 quotaLoaded: true * - onDone 在 state machine 赋 guestRemainingQuota + guestTotalQuota + 设 quotaLoaded: true
*/ */
export const loadQuotaActor = fromPromise<{ export const loadQuotaActor = fromPromise<{
remaining: number; remaining: number;
@@ -49,13 +49,13 @@ export const loadQuotaActor = fromPromise<{
}); });
// ============================================================ // ============================================================
// Init 任务 2:拉 history 3 步流(fromPromise**返**回**最**终**结**果** // Init 任务 2:拉 history 3 步流(fromPromise返回最终结果
// ============================================================ // ============================================================
/** /**
* local → network → save network to local 3 步流(**返**回** network **最**终** messages** * local → network → save network to local 3 步流(返回 network 最终 messages
* - 选**方**案 AfromPromise):UI **不**会**看**到** local 中**间**态****直**接**显**示** network **结**果** * - 选案 AfromPromise):UI 不会看到 local 中间态,直接显示 network 结果
* - 内部 3 步**走** helper `readAndSyncHistory`**日**志**全**在** helper **里** * - 内部 3 步 helper `readAndSyncHistory`日志全在 helper
* - 失**败**返 local**退**而**求**其**次** * - 失返 local退而求其次
*/ */
export const loadHistoryActor = fromPromise<{ export const loadHistoryActor = fromPromise<{
messages: UiMessage[]; messages: UiMessage[];
@@ -81,10 +81,10 @@ export const loadHistoryActor = fromPromise<{
// HTTP: one-shot Promise // HTTP: one-shot Promise
// ============================================================ // ============================================================
/** /**
* HTTP 发送消息(**一次发送 = 一次返回** * HTTP 发送消息(一次发送 = 一次返回)
* - 后端响应**就**是 AI 回复(`ChatSendResponse.reply: string` * - 后端响应是 AI 回复(`ChatSendResponse.reply: string`
* - **不**再调 `getLocalMessages()`**多此一举** * - 再调 `getLocalMessages()`(多此一举)
* - 返回 `{ reply: UiMessage }` —— `sending.onDone` 把它**追加**到 `context.messages` * - 返回 `{ reply: UiMessage }` —— `sending.onDone` 把它追加到 `context.messages`
*/ */
export const sendMessageHttpActor = fromPromise< export const sendMessageHttpActor = fromPromise<
{ reply: UiMessage }, { reply: UiMessage },
@@ -122,7 +122,7 @@ export const sendMessageHttpActor = fromPromise<
return { reply }; return { reply };
}); });
/** 翻历史(pagination)—— **不**走 local-first**纯** server fetch */ /** 翻历史(pagination)—— 走 local-first server fetch */
export const loadMoreHistoryActor = fromPromise< export const loadMoreHistoryActor = fromPromise<
{ messages: UiMessage[]; hasMore: boolean; newOffset: number }, { messages: UiMessage[]; hasMore: boolean; newOffset: number },
{ offset: number } { offset: number }
@@ -173,13 +173,13 @@ export const loadMoreHistoryActor = fromPromise<
// WebSocket: long-lived callback // WebSocket: long-lived callback
// ============================================================ // ============================================================
/** /**
* 长生命周期 WebSocket**仅** `userSession` 期间 invoke * 长生命周期 WebSocket `userSession` 期间 invoke
* - 启动:连 WS,事件 → sendBack 派回机器 * - 启动:连 WS,事件 → sendBack 派回机器
* - 停止(cleanup):disconnect * - 停止(cleanup):disconnect
* - **游客**不** invoke 此 actor(业务事实"游客不连 WS" * - 游客不 invoke 此 actor(业务事实"游客不连 WS"
* *
* 之所以用 fromCallback 而非 fromPromise * 之所以用 fromCallback 而非 fromPromise
* - WS 是长连接,**不**是一次性 Promise * - WS 是长连接,是一次性 Promise
* - fromCallback 提供 `sendBack` 回调,actor 内部事件 → 机器 * - fromCallback 提供 `sendBack` 回调,actor 内部事件 → 机器
* - cleanup 函数 → XState 在 parent state exit 时自动调用 * - cleanup 函数 → XState 在 parent state exit 时自动调用
*/ */
+31 -31
View File
@@ -4,20 +4,20 @@
* 从 `chat-machine.ts` 抽出的"helpers"段: * 从 `chat-machine.ts` 抽出的"helpers"段:
* - 翻页大小常量 * - 翻页大小常量
* - 仓库注入(接口类型 → 单例) * - 仓库注入(接口类型 → 单例)
* - DTO ↔ UiMessage 映射(**纯函数** —— 可单测) * - DTO ↔ UiMessage 映射(纯函数 —— 可单测)
* - Result → number 映射(**纯函数** —— 可单测) * - Result → number 映射(纯函数 —— 可单测)
* - 2 **个**独立 init 任务**的**数据加载** * - 2 独立 init 任务数据加载:
* - `readGuestQuota()` —— 游客**日**配 + 总**配****仅**游客有意义,**纯** ChatStorage * - `readGuestQuota()` —— 游客日配 + 总配(仅游客有意义, ChatStorage
* - `readAndSyncHistory()` —— local → network → save network to local 3 **步** * - `readAndSyncHistory()` —— local → network → save network to local 3
* *
* 设计目标: * 设计目标:
* - helpers **无 XState 依赖**(可独立 import / 测试) * - helpers 无 XState 依赖(可独立 import / 测试)
* - actors 依赖 helpersimport * - actors 依赖 helpersimport
* - machine 依赖 actorsimport)—— **不**直接依赖 helpers * - machine 依赖 actorsimport)—— 直接依赖 helpers
* *
* 历史: * 历史:
* - `readInitData` + `InitResult` 已**删****被** 2 **个**独立 helper **替**换** * - `readInitData` + `InitResult` 已删(被 2 个独立 helper 替换
* - `AuthStorage` import **已**删****只** `readInitData` 用过,**移**到** `chatInit` actor **上**层**调**用** * - `AuthStorage` import 已删(只 `readInitData` 用过,移到 `chatInit` actor 上层调用
*/ */
import type { UiMessage } from "@/models/chat/ui-message"; import type { UiMessage } from "@/models/chat/ui-message";
@@ -31,7 +31,7 @@ import { Result } from "@/utils/result";
// ============================================================ // ============================================================
// Constants // Constants
// ============================================================ // ============================================================
/** 翻历史每页大小(**仅** `loadMoreHistoryActor` 用) */ /** 翻历史每页大小( `loadMoreHistoryActor` 用) */
export const PAGE_SIZE = 20; export const PAGE_SIZE = 20;
// ============================================================ // ============================================================
@@ -44,7 +44,7 @@ export const chatRepo: IChatRepository = chatRepository;
// DTO ↔ UiMessage 映射 // DTO ↔ UiMessage 映射
// ============================================================ // ============================================================
/** /**
* ChatMessage[] → UiMessage[]**纯函数** * ChatMessage[] → UiMessage[](纯函数)
* - 与 Dart `LocalMessage → ChatMessage` 等价 * - 与 Dart `LocalMessage → ChatMessage` 等价
* - 业务事实:`role === "assistant"` → `isFromAI: true` * - 业务事实:`role === "assistant"` → `isFromAI: true`
*/ */
@@ -63,9 +63,9 @@ export function localMessagesToUi(
} }
/** /**
* ChatSendResponse → UiMessage**纯函数** * ChatSendResponse → UiMessage(纯函数)
* - 业务事实:后端响应**就是** AI 的回复 * - 业务事实:后端响应就是 AI 的回复
* - 用后端 `timestamp`**不**用本地 time)—— 多设备/时区**一致** * - 用后端 `timestamp`用本地 time)—— 多设备/时区一致
*/ */
export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage { export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
return { return {
@@ -78,7 +78,7 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
// ============================================================ // ============================================================
// Result → number 映射 // Result → number 映射
// ============================================================ // ============================================================
/** 日配额 Result → number**纯函数** */ /** 日配额 Result → number(纯函数) */
export function mapQuotaResult( export function mapQuotaResult(
r: Result<{ remaining: number } | null>, r: Result<{ remaining: number } | null>,
fallback: number, fallback: number,
@@ -87,7 +87,7 @@ export function mapQuotaResult(
return fallback; return fallback;
} }
/** 总配额 Result → number**纯函数** */ /** 总配额 Result → number(纯函数) */
export function mapTotalQuotaResult( export function mapTotalQuotaResult(
r: Result<number | null>, r: Result<number | null>,
fallback: number, fallback: number,
@@ -118,26 +118,26 @@ export async function readGuestQuota(): Promise<{
// Init 任务 2history 3 步流(local → network → save)—— `loadHistoryActor` 调 // Init 任务 2history 3 步流(local → network → save)—— `loadHistoryActor` 调
// ============================================================ // ============================================================
/** /**
* 拉 history **流**程(**每**步**都**记**日**志** —— 用户要求"**不**要**删**除**" * 拉 history 流程(每步都记日志 —— 用户要求"不要删除"
* 1. **读** localchatRepo.getLocalMessages)→ **返** localMessages * 1. localchatRepo.getLocalMessages)→ localMessages
* 2. **读** networkchatRepo.getHistory)→ **返** networkMessages * 2. networkchatRepo.getHistory)→ networkMessages
* 3. **用** network **覆**盖** localchatRepo.saveMessagesToLocal)→ local **被**同步 * 3. network 覆盖 localchatRepo.saveMessagesToLocal)→ local 同步
* *
* **返**回** network **最**终** messages**authorititative),**不**返** localUI **不**需**要**过渡**态** * 返回 network 最终 messagesauthorititative),不返 localUI 不需要过渡态
* *
* **保**留**日**志****前**缀 `[chat-machine]` 与 chat-machine.actors.ts **对**齐** * 保留日志(前缀 `[chat-machine]` 与 chat-machine.actors.ts 对齐
*/ */
export async function readAndSyncHistory(): Promise<{ export async function readAndSyncHistory(): Promise<{
messages: UiMessage[]; // network **最**终** messagesauthorititative messages: UiMessage[]; // network 最终 messagesauthorititative
hasMore: boolean; hasMore: boolean;
newOffset: number; newOffset: number;
localOverwritten: boolean; // true = local **已**被 network **覆**盖****写**入** ChatStorage localOverwritten: boolean; // true = local 被 network 覆盖(写入 ChatStorage
/** 真实从 local 读到的消息数(**调**试**用** */ /** 真实从 local 读到的消息数(调试用 */
localCount: number; localCount: number;
/** 真实从 network 读到的消息数(**调**试**用** */ /** 真实从 network 读到的消息数(调试用 */
networkCount: number; networkCount: number;
}> { }> {
// 1. **读** local // 1. local
const localResult = await chatRepo.getLocalMessages(); const localResult = await chatRepo.getLocalMessages();
const localMessages = const localMessages =
Result.isOk(localResult) && localResult.data Result.isOk(localResult) && localResult.data
@@ -147,16 +147,16 @@ export async function readAndSyncHistory(): Promise<{
count: localMessages.length, count: localMessages.length,
}); });
// 2. **读** network // 2. network
const networkResult = await chatRepo.getHistory(PAGE_SIZE, 0); const networkResult = await chatRepo.getHistory(PAGE_SIZE, 0);
if (!Result.isOk(networkResult)) { if (!Result.isOk(networkResult)) {
// network 失**败** —— **返**空 messages**不**让 UI 卡**住** // network 失败 —— 返空 messages让 UI 卡
console.error( console.error(
"[chat-machine] loadHistory NETWORK FAILED", "[chat-machine] loadHistory NETWORK FAILED",
networkResult.success ? null : (networkResult as { error: unknown }).error, networkResult.success ? null : (networkResult as { error: unknown }).error,
); );
return { return {
messages: localMessages, // **退**而**求**其**次**:返 local**不**然**屏**幕**空** messages: localMessages, // 退而求其次:返 local(不然屏幕空
hasMore: false, hasMore: false,
newOffset: 0, newOffset: 0,
localOverwritten: false, localOverwritten: false,
@@ -169,7 +169,7 @@ export async function readAndSyncHistory(): Promise<{
count: networkUi.length, count: networkUi.length,
}); });
// 3. **用** network **覆**盖** local"**再**用**网**络**数**据**覆**盖**本**地**数**据"**这**句**的**字**面**实**现** // 3. network 覆盖 local("再用网络数据覆盖本地数据"这句的字面实现
const saveResult = await chatRepo.saveMessagesToLocal(networkResult.data.messages); const saveResult = await chatRepo.saveMessagesToLocal(networkResult.data.messages);
const localOverwritten = Result.isOk(saveResult); const localOverwritten = Result.isOk(saveResult);
console.log("[chat-machine] loadHistory SAVE TO LOCAL DONE", { console.log("[chat-machine] loadHistory SAVE TO LOCAL DONE", {
+51 -46
View File
@@ -1,39 +1,40 @@
/** /**
* Chat 状态机(XState v5 * Chat 状态机(XState v5
* *
* **鉴权解耦****事件驱动**): * 鉴权解耦事件驱动):
* chat 机器**不**感知鉴权 / **不**管 WebSocket —— 由 chat-screen 派生 loginStatus * chat 机器感知鉴权 / 管 WebSocket —— 由 chat-screen 派生 loginStatus
* chat-screen **只**派 3 个事件: * chat-screen 派 3 个事件:
* - `ChatGuestLogin` → 游客会话(**断** WS = 不连) * - `ChatGuestLogin` → 游客会话( WS = 不连)
* - `ChatNonGuestLogin { token }` → 非游客会话(**连** WS * - `ChatNonGuestLogin { token }` → 非游客会话( WS
* - `ChatLogout` → 登出(机器自动 cleanup WS actor * - `ChatLogout` → 登出(机器自动 cleanup WS actor
* *
* **状态结构**parent state 模式): * 状态结构(parent state 模式):
* - `idle`:屏没挂 / 登出 * - `idle`:屏没挂 / 登出
* - `guestSession`**parent**):游客会话 —— **不** invoke WS * - `guestSession`parent):游客会话 —— invoke WS
* - `userSession`**parent**):非游客会话 —— **invoke WS**(持久) * - `userSession`parent):非游客会话 —— invoke WS(持久)
* *
* **init 双任务****核**心**重**构**): * init 双任务(核心重构):
* - guestSession.initializingloadQuota + loadHistory **并**行**`invoke: [...]` **数**组** * - guestSession.initializingloadQuota + loadHistory 并行`invoke: [...]` 数组
* - userSession.initializingloadHistory + parent-level chatWebSocket **并** * - userSession.initializingloadHistory + parent-level chatWebSocket
* - **每**个**任务**都**是**独**立** actor**不**互**相**等**待****除**非** `always` barrier * - 每个任务都是独立 actor,不互相等待(除非 `always` barrier
* *
* **消息发送路径****业务事实**): * 消息发送路径业务事实):
* - `wsConnected: true`**仅** userSession 期间)→ **走 WS****不**消耗次数(**仅**配额规则) * - `wsConnected: true` userSession 期间)→ 走 WS,不消耗次数(仅配额规则)
* - `wsConnected: false` → **走 HTTP**,消耗次数(**仅**游客有意义 —— 非游客 0-1=max(0,-1)=0 天然 no-op * - `wsConnected: false` → 走 HTTP,消耗次数(游客有意义 —— 非游客 0-1=max(0,-1)=0 天然 no-op
* - `ChatWebSocketConnected` 事件 → `wsConnected = true` * - `ChatWebSocketConnected` 事件 → `wsConnected = true`
* - `userSession.exit` action → `wsConnected = false`WS actor cleanup 时**也**会跑 exit * - `userSession.exit` action → `wsConnected = false`WS actor cleanup 时会跑 exit
* *
* **配额(**仅**游客)** * 配额(游客):
* - 非游客"无消息限制"(业务事实)—— 永不被赋值 * - 非游客"无消息限制"(业务事实)—— 永不被赋值
* - `appendUserMessage` 减 `context.wsConnected ? q : Math.max(0, q-1)` —— WS 已连时**不**递减 * - `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 { ChatStorage } from "@/data/storage/chat/chat_storage";
import { formatDate } from "@/utils/date"; import { formatDate, todayString } from "@/utils/date";
import { ChatState, initialState } from "./chat-state"; import { ChatState, initialState } from "./chat-state";
import type { ChatEvent } from "./chat-events"; import type { ChatEvent } from "./chat-events";
@@ -75,7 +76,7 @@ export const chatMachine = setup({
appendUserMessage: assign(({ context, event }) => { appendUserMessage: assign(({ context, event }) => {
if (event.type !== "ChatSendMessage") return {}; if (event.type !== "ChatSendMessage") return {};
// 两**个**额度**同**步**更**新**WS 已**连****不**递减(**实**时**会**话**不**消耗**),HTTP **必**须** // 两个额度同步更新:WS 已连不递减(实时会话不消耗),HTTP 必须
const newRemaining = context.wsConnected const newRemaining = context.wsConnected
? context.guestRemainingQuota ? context.guestRemainingQuota
: Math.max(0, context.guestRemainingQuota - 1); : Math.max(0, context.guestRemainingQuota - 1);
@@ -83,10 +84,11 @@ export const chatMachine = setup({
? context.guestTotalQuota ? context.guestTotalQuota
: Math.max(0, context.guestTotalQuota - 1); : Math.max(0, context.guestTotalQuota - 1);
// 持**久**化**两**个**额度**** fire-and-forget**不** await —— assign **是** sync // 持久化两个额度( fire-and-forget await —— assign sync
// daily quota 需**要** today **字**符**串****让** ChatStorage 跨**天**判**断** reset // daily quota 需 today 字符串(让 ChatStorage 跨天判断 reset
const today = formatDate(); const today = todayString();
ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today); ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today);
ChatStorage.getInstance().setGuestTotalQuota(newTotal); ChatStorage.getInstance().setGuestTotalQuota(newTotal);
console.log("[chat-machine] appendUserMessage", { console.log("[chat-machine] appendUserMessage", {
@@ -117,16 +119,19 @@ export const chatMachine = setup({
appendUserImage: assign(({ context, event }) => { appendUserImage: assign(({ context, event }) => {
if (event.type !== "ChatSendImage") return {}; if (event.type !== "ChatSendImage") return {};
// **同** appendUserMessage:两**个**额度**同**步** // appendUserMessage:两个额度同步
const newRemaining = context.wsConnected const newRemaining = context.wsConnected
? context.guestRemainingQuota ? context.guestRemainingQuota
: Math.max(0, context.guestRemainingQuota - 1); : Math.max(0, context.guestRemainingQuota - 1);
const newTotal = context.wsConnected const newTotal = context.wsConnected
? context.guestTotalQuota ? context.guestTotalQuota
: Math.max(0, context.guestTotalQuota - 1); : Math.max(0, context.guestTotalQuota - 1);
const today = formatDate();
const today = todayString();
void ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today); void ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today);
void ChatStorage.getInstance().setGuestTotalQuota(newTotal); void ChatStorage.getInstance().setGuestTotalQuota(newTotal);
console.log("[chat-machine] appendUserImage", { console.log("[chat-machine] appendUserImage", {
oldMessagesCount: context.messages.length, oldMessagesCount: context.messages.length,
wsConnected: context.wsConnected, wsConnected: context.wsConnected,
@@ -211,13 +216,13 @@ export const chatMachine = setup({
}, },
// ======================================================================== // ========================================================================
// guestSession(游客会话 —— **不**连 WS2 **个** init 任务**并**行** // guestSession(游客会话 —— 连 WS2 init 任务并行
// ======================================================================== // ========================================================================
guestSession: { guestSession: {
initial: "initializing", initial: "initializing",
states: { states: {
initializing: { initializing: {
// "always" barrier:两**个**任务**都**完**成**才**进** ready // "always" barrier:两个任务都完成才进 ready
always: [ always: [
{ {
target: "ready", target: "ready",
@@ -225,7 +230,7 @@ export const chatMachine = setup({
}, },
], ],
invoke: [ invoke: [
// 任务 1:加载配额(fromPromise**返**结果 + onDone 设 flag // 任务 1:加载配额(fromPromise结果 + onDone 设 flag
{ {
id: "loadQuota", id: "loadQuota",
src: "loadQuota", src: "loadQuota",
@@ -237,13 +242,13 @@ export const chatMachine = setup({
})), })),
}, },
onError: { onError: {
// 失败**也**标 loaded**不**卡 init**游**客**可**能 quota 服务**不**可**用****让** UI **进** ready // 失败标 loaded卡 init游客可能 quota 服务不可用,让 UI 进 ready
actions: assign({ actions: assign({
quotaLoaded: true, quotaLoaded: true,
}), }),
}, },
}, },
// 任务 2:拉 historyfromPromise**返**回**最**终** network messages** // 任务 2:拉 historyfromPromise返回最终 network messages
{ {
id: "loadHistory", id: "loadHistory",
src: "loadHistory", src: "loadHistory",
@@ -256,7 +261,7 @@ export const chatMachine = setup({
})), })),
}, },
onError: { onError: {
// 失败**也**标 loaded**不**卡 init**让** UI **还**是**能**进** ready**屏**幕**可**能**空** // 失败标 loaded卡 init让 UI 还是能进 ready,屏幕可能空
actions: assign({ actions: assign({
historyLoaded: true, historyLoaded: true,
}), }),
@@ -266,29 +271,29 @@ export const chatMachine = setup({
}, },
ready: { ready: {
on: { on: {
// **配**额**检**查** + 发**送**消息**3 **条** guard**按**顺**序**匹**配** // 配额检查 + 发送消息(3 条 guard,按顺序匹配
// 1. **总**配**额**guestTotalQuota**不**足 → **触**发** quota exceeded dialog**不**发**送** // 1. 总配额guestTotalQuota不足 → 触发 quota exceeded dialog不发送
// 2. **每**日**配**额**guestRemainingQuota**不**足 → **同**上** // 2. 每日配额guestRemainingQuota不足 → 同上
// 3. **两**个**配**额**都** OK + **内**容**非**空** → **发**送** + **减**配**额** // 3. 两个配额都 OK + 内容非空 → 发送 + 减配额
ChatSendMessage: [ ChatSendMessage: [
// 1. 总配**额** **不**足**guestTotalQuota <= 0**触**发** quota exceeded dialog // 1. 总配额 不足guestTotalQuota <= 0触发 quota exceeded dialog
{ {
guard: ({ context }) => context.guestTotalQuota <= 0, guard: ({ context }) => context.guestTotalQuota <= 0,
actions: "incrementQuotaExceeded", actions: "incrementQuotaExceeded",
}, },
// 2. 每**日**配**额** **不**足**guestRemainingQuota <= 0**触**发** quota exceeded dialog // 2. 每日配额 不足guestRemainingQuota <= 0触发 quota exceeded dialog
{ {
guard: ({ context }) => context.guestRemainingQuota <= 0, guard: ({ context }) => context.guestRemainingQuota <= 0,
actions: "incrementQuotaExceeded", actions: "incrementQuotaExceeded",
}, },
// 3. 两**个**配**额**都** OK + **内**容**非**空** → **发**送****只**用** appendUserMessage **自**己**的**日**志** // 3. 两个配额都 OK + 内容非空 → 发送(只用 appendUserMessage 自己的日志
{ {
actions: "appendUserMessage", actions: "appendUserMessage",
guard: ({ event }) => event.content.trim().length > 0, guard: ({ event }) => event.content.trim().length > 0,
target: "sending", target: "sending",
}, },
], ],
// **配**额**检**查** + 发**送**图**片****同**样** 3 **条** guard**不**检**查** "内**容**空"** // 配额检查 + 发送图片(同样 3 条 guard,不检查 "内容空"
ChatSendImage: [ ChatSendImage: [
{ {
guard: ({ context }) => context.guestTotalQuota <= 0, guard: ({ context }) => context.guestTotalQuota <= 0,
@@ -303,7 +308,7 @@ export const chatMachine = setup({
target: "sending", target: "sending",
}, },
], ],
// **删**除 ChatLoadMoreHistory handler —— 游客**无**服**务**端** history**不**支**持**翻**页** // 除 ChatLoadMoreHistory handler —— 游客无服务端 history,不支持翻页
ChatLogout: "#chat.idle", ChatLogout: "#chat.idle",
ChatNonGuestLogin: "#chat.userSession", ChatNonGuestLogin: "#chat.userSession",
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" }, ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
@@ -331,18 +336,18 @@ export const chatMachine = setup({
}, },
}, },
}, },
// **删**除 loadingMore(游客**无**翻**页** // 除 loadingMore(游客无翻页
}, },
}, },
// ======================================================================== // ========================================================================
// userSession(非游客会话 —— WS **父**级 + history **子**级** // userSession(非游客会话 —— WS 级 + history 子级
// ======================================================================== // ========================================================================
userSession: { userSession: {
initial: "initializing", initial: "initializing",
exit: "clearWsConnected", exit: "clearWsConnected",
invoke: { invoke: {
// 父级:WS 长**连**接(**一**进**来****就**连,跨 initializing/ready/sending/loadingMore // 父级:WS 长连接(一进来就连,跨 initializing/ready/sending/loadingMore
src: "chatWebSocket", src: "chatWebSocket",
input: ({ event }) => ({ input: ({ event }) => ({
token: event.type === "ChatNonGuestLogin" ? event.token : "", token: event.type === "ChatNonGuestLogin" ? event.token : "",
@@ -350,7 +355,7 @@ export const chatMachine = setup({
}, },
states: { states: {
initializing: { initializing: {
// barrierWS **连**上** + history **加**载**完**成**才**进** ready // barrierWS 连上 + history 加载完成才进 ready
always: [ always: [
{ {
target: "ready", target: "ready",
@@ -358,7 +363,7 @@ export const chatMachine = setup({
}, },
], ],
invoke: { invoke: {
// 任务 2:拉 history**只**调**用** loadHistoryActor**不**再**用** loadMoreHistoryActor // 任务 2:拉 history只调用 loadHistoryActor不再用 loadMoreHistoryActor
src: "loadHistory", src: "loadHistory",
onDone: { onDone: {
actions: assign(({ event }) => ({ actions: assign(({ event }) => ({
@@ -370,7 +375,7 @@ export const chatMachine = setup({
})), })),
}, },
onError: { onError: {
// 失败**也**标 loaded**不**卡 init // 失败标 loaded卡 init
actions: assign({ actions: assign({
isLoadingMore: false, isLoadingMore: false,
historyLoaded: true, historyLoaded: true,
+13 -20
View File
@@ -3,18 +3,11 @@
* *
* 注:GuestChatQuota 仍位于 chat-machine.ts(属于业务常量,与上下文配对紧密)。 * 注:GuestChatQuota 仍位于 chat-machine.ts(属于业务常量,与上下文配对紧密)。
* *
* 历史 * 新增 `wsConnected: boolean`(仅 userSession 期间 true
* - `isGuest: boolean` 字段已**删**(由 chat-screen 派生自 `auth.loginStatus === "guest"` * - WS 已连 → 消息发送走 WS,不消耗次数
* - `guestRemainingQuota` / `guestTotalQuota` default **改 0** * - WS 未连 → 消息发送走 HTTP,消耗次数
* - 游客:chat-screen 派 `ChatInit` → readInitData 调 ChatStorage → onDone 条件赋
* - 非游客:**永不被赋值**(业务事实"非游客无消息限制")—— 0 - 1 = max(0, -1) = 0 天然 no-op
* - 0 同时表示"未初始化"和"已耗尽" —— UI 用 `if (isGuest && ...)` 天然过滤
*
* 新增 `wsConnected: boolean`**仅** userSession 期间 true):
* - **WS 已连** → 消息发送走 WS,**不**消耗次数
* - **WS 未连** → 消息发送走 HTTP,消耗次数
* - `ChatWebSocketConnected` 事件 → true * - `ChatWebSocketConnected` 事件 → true
* - `userSession.exit` → falseWS actor cleanup 时**也**会跑 exit * - `userSession.exit` → falseWS actor cleanup 时会跑 exit
*/ */
import type { UiMessage } from "@/models/chat/ui-message"; import type { UiMessage } from "@/models/chat/ui-message";
@@ -22,13 +15,13 @@ export interface ChatState {
messages: UiMessage[]; messages: UiMessage[];
isReplyingAI: boolean; isReplyingAI: boolean;
/** /**
* WebSocket 连接状态(**仅** userSession 期间 true * WebSocket 连接状态( userSession 期间 true
* - 决定消息发送**路径**WS / HTTP)和**是否消耗**次数 * - 决定消息发送路径WS / HTTP)和是否消耗次数
* - true:走 WS**不**消耗次数 * - true:走 WS消耗次数
* - false:走 HTTP,消耗次数 * - false:走 HTTP,消耗次数
*/ */
wsConnected: boolean; wsConnected: boolean;
/** 游客剩余配额(次/天)—— **仅**游客业务展示用 /** 游客剩余配额(次/天)—— 游客业务展示用
*/ */
guestRemainingQuota: number; guestRemainingQuota: number;
/** 游客总配额(仅游客展示用) */ /** 游客总配额(仅游客展示用) */
@@ -37,14 +30,14 @@ export interface ChatState {
isLoadingMore: boolean; isLoadingMore: boolean;
hasMore: boolean; hasMore: boolean;
historyOffset: number; historyOffset: number;
/** 游客配额加载完成标**志****仅** guestSession 期间) —— default false /** 游客配额加载完成标志(仅 guestSession 期间) —— default false
* - guestSession.initializing **走** always barrier`quotaLoaded && historyLoaded` **才**进** ready * - guestSession.initializing always barrier`quotaLoaded && historyLoaded` 才进 ready
* - userSession **永不为** true**不**调 loadQuota)—— OK**不**影响 barrier * - userSession 永不为 true调 loadQuota)—— OK影响 barrier
*/ */
quotaLoaded: boolean; quotaLoaded: boolean;
/** history 加**载**完成标**志**initial load —— local → network → save 跑**完** /** history 加载完成标志initial load —— local → network → save 跑
* - guestSession.initializing / userSession.initializing 走 always barrier * - guestSession.initializing / userSession.initializing 走 always barrier
* - **不**被 `loadMoreHistoryActor` 设**置****翻**页**是**另**一**码**事** * - 被 `loadMoreHistoryActor` 设置(翻页是另一码事
*/ */
historyLoaded: boolean; historyLoaded: boolean;
} }
+6 -2
View File
@@ -7,8 +7,12 @@
*/ */
@theme { @theme {
/* 字体族 */ /* 字体族 */
--font-system: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", /*
Roboto, "Helvetica Neue", Arial, sans-serif; * --font-system 优先用 AthelasApple 平台专用字体 next/font/local所有平台都能加载
* 不支持的字符自动回退到 system-ui 等系统字体浏览器 font fallback 机制
*/
--font-system: var(--font-athelas), system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-athelas: "Athelas", serif; --font-athelas: "Athelas", serif;
/* Tailwind 默认字体别名:font-sans → font-system */ /* Tailwind 默认字体别名:font-sans → font-system */
+2 -2
View File
@@ -57,9 +57,9 @@ export const Result = {
* *
* *
* 1. `toError` .stack / .code / .status * 1. `toError` .stack / .code / .status
* 2. ** Logger ** stack + component=Result dev * 2. Logger stack + component=Result dev
* JSON pipeline * JSON pipeline
* 3. **** `Result<T>`throw * 3. `Result<T>`throw
* `throw Result.unwrap(r)` * `throw Result.unwrap(r)`
*/ */
async wrap<T>(thunk: () => Promise<T>): Promise<Result<T>> { async wrap<T>(thunk: () => Promise<T>): Promise<Result<T>> {