diff --git a/src/app/auth/components/auth-legal-text.tsx b/src/app/auth/components/auth-legal-text.tsx index 83079e50..c0623aba 100644 --- a/src/app/auth/components/auth-legal-text.tsx +++ b/src/app/auth/components/auth-legal-text.tsx @@ -13,7 +13,7 @@ */ import { useState } from "react"; -import { AppConstants } from "@/core/constants/app_constants"; +import { AppConstants } from "@/core/app_constants"; import styles from "./auth-legal-text.module.css"; diff --git a/src/app/auth/components/auth-screen.tsx b/src/app/auth/components/auth-screen.tsx index edcb0b8b..12d836be 100644 --- a/src/app/auth/components/auth-screen.tsx +++ b/src/app/auth/components/auth-screen.tsx @@ -10,7 +10,6 @@ import { useRouter } from "next/navigation"; import { MobileShell } from "@/app/_components/core/mobile-shell"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; -import { useChatDispatch } from "@/stores/chat/chat-context"; import { useUserDispatch } from "@/stores/user/user-context"; import { ROUTES } from "@/router/routes"; import styles from "./auth-screen.module.css"; @@ -22,15 +21,14 @@ export function AuthScreen() { // ===== 鉴权路由跳转已由 src/router/proxy.ts 集中处理 ===== // 本组件只负责: // 1. 渲染登录 UI - // 2. 邮箱登录成功 → 初始化 chat/user store + navigate 到 /chat(业务层动作) + // 2. 邮箱登录成功 → 初始化 user store + navigate 到 /chat(业务层动作) const state = useAuthState(); const authDispatch = useAuthDispatch(); - const chatDispatch = useChatDispatch(); const userDispatch = useUserDispatch(); const router = useRouter(); - // 邮箱登录成功 → 通知 Chat + User + 跳转 + // 邮箱登录成功 → 通知 User + 跳转 // 用 **pendingRedirect flag**(不是 useRef transition detection)—— // re-mount 时 flag 在 auth context 里**持久**,区分"刚按了"和"re-visit"准确无误。 useEffect(() => { @@ -46,7 +44,6 @@ export function AuthScreen() { "[auth-screen] useEffect → router.replace(/chat) [via pendingRedirect flag]", ); authDispatch({ type: "AuthClearPendingRedirect" }); - chatDispatch({ type: "ChatAuthStatusChanged" }); userDispatch({ type: "UserInit" }); router.replace(ROUTES.chat); } @@ -54,7 +51,6 @@ export function AuthScreen() { state.loginStatus, state.pendingRedirect, authDispatch, - chatDispatch, userDispatch, router, ]); diff --git a/src/app/chat/components/chat-screen.tsx b/src/app/chat/components/chat-screen.tsx index 9cbf20d8..fde5117b 100644 --- a/src/app/chat/components/chat-screen.tsx +++ b/src/app/chat/components/chat-screen.tsx @@ -5,24 +5,43 @@ * 原始 Dart: lib/ui/chat/chat_screen.dart(172 行) * * 职责(本文件): + * - **订阅 auth 状态机**(loginStatus)—— + * - 派生 isGuest / 驱动 WS 生命周期 / 驱动初次加载(按 loginStatus 选 ChatInit vs ChatLoadMoreHistory) + * - 驱动历史加载 * - 屏幕生命周期事件(init / visible / invisible) - * - 配额告警监听(quotaExceededTrigger + warningThreshold) + * - 配额告警监听(**仅**游客 —— "非游客无消息限制"业务事实) + * - WebSocket 生命周期管理(基于 auth 状态机的 loginStatus) * - 渲染骨架(背景 + 顶部栏 + 消息区 + 输入栏) * * 子组件职责: * - ChatHeader:顶部栏(游客 banner / 菜单按钮) * - ChatArea:消息列表 * - ChatInputBar:输入栏(文字 + 发送 + More) - * - QuotaDialog:配额告警弹窗 + * - QuotaDialog:配额告警弹窗(**仅**游客) * - PwaInstallOverlay:PWA 安装提示触发器 * - BrowserHintOverlay:浏览器提示 * - CharacterIntro:角色介绍卡片(首屏) + * + * **鉴权解耦**: + * chat 机器**不**感知鉴权 —— isGuest 在此派生自 `auth.loginStatus === "guest"`。 + * chat 机器**不**管 WebSocket 生命周期 —— 由此 useEffect 监听 loginStatus 驱动。 + * + * **配额(**仅**游客)**: + * - 游客:派 ChatInit → readInitData 调 ChatStorage → onDone 条件赋 + * - 非游客:派 ChatLoadMoreHistory(无配额 init)—— "非游客无消息限制"业务事实 */ import { useEffect, useRef, useState } from "react"; import Image from "next/image"; +import { useAuthState } from "@/stores/auth/auth-context"; +import type { LoginStatus } from "@/models/auth/login-status"; +import { AuthStorage } from "@/data/storage/auth/auth_storage"; import { useChatDispatch, useChatState } from "@/stores/chat/chat-context"; import { GuestChatQuota } from "@/stores/chat"; +import { + ChatWebSocket, + createChatWebSocket, +} from "@/core/net/chat-websocket"; import { MobileShell } from "@/app/_components/core/mobile-shell"; @@ -34,25 +53,154 @@ import { PwaInstallOverlay } from "./pwa-install-overlay"; import { QuotaDialog } from "./quota-dialog"; import styles from "./chat-screen.module.css"; +/** + * 派生 isGuest —— 单一来源:`auth.loginStatus` + */ +function deriveIsGuest(loginStatus: LoginStatus): boolean { + return loginStatus === "guest"; +} + +/** + * 拿 WebSocket token —— 按 loginStatus 选 loginToken 或 guestToken + */ +async function getWsToken( + loginStatus: LoginStatus, +): Promise { + if (loginStatus === "notLoggedIn") return null; + const storage = AuthStorage.getInstance(); + if (loginStatus === "guest") { + const r = await storage.getGuestToken(); + return r.success ? r.data : null; + } + // email / google / facebook / apple:都用业务 loginToken(OAuth sync 写下的) + const r = await storage.getLoginToken(); + return r.success ? r.data : null; +} + +/** + * 按 loginStatus 派初次加载事件(**仅**游客派 ChatInit —— 配额 init) + */ +function dispatchInitialLoad( + loginStatus: LoginStatus, + chatDispatch: ReturnType, +): void { + if (loginStatus === "guest") { + chatDispatch({ type: "ChatInit" }); // 拉本地消息 + 初始化配额 + } else if (loginStatus !== "notLoggedIn") { + chatDispatch({ type: "ChatLoadMoreHistory" }); // 拉服务器端首屏(**无**配额 init) + } +} + export function ChatScreen() { const state = useChatState(); - const dispatch = useChatDispatch(); + const authState = useAuthState(); + const chatDispatch = useChatDispatch(); + + // isGuest 派生(chat-screen 是**唯一**知道这个值的地方 —— chat 机器无 isGuest 字段) + const isGuest = deriveIsGuest(authState.loginStatus); const [quotaDialogOpen, setQuotaDialogOpen] = useState(false); const [quotaExhausted, setQuotaExhausted] = useState(false); - // 屏幕生命周期 + // ───────────────────────────────────────────────────────────── + // 屏幕生命周期 —— mount 时按 loginStatus 派初次加载 + // ───────────────────────────────────────────────────────────── useEffect(() => { - dispatch({ type: "ChatInit" }); - dispatch({ type: "ChatScreenVisible" }); + chatDispatch({ type: "ChatScreenVisible" }); + dispatchInitialLoad(authState.loginStatus, chatDispatch); return () => { - dispatch({ type: "ChatScreenInvisible" }); + chatDispatch({ type: "ChatScreenInvisible" }); }; - }, [dispatch]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chatDispatch]); + // 注:mount useEffect 故意**不**依赖 loginStatus —— 首次 mount 时机是固定的 + // ("用户进 /chat 那一刻"),loginStatus 由 splash/auth-screen 提前 settle。 + // loginStatus 后续变化由下方"副作用 ②" 统一处理。 - // 配额耗尽触发器 + // ───────────────────────────────────────────────────────────── + // 核心副作用 ①:loginStatus 变化 → 连/断 WebSocket + // ───────────────────────────────────────────────────────────── + const wsRef = useRef(null); + + useEffect(() => { + let cancelled = false; + + void (async () => { + if (authState.loginStatus === "notLoggedIn") { + // 登出 / 未登录:断开 WS + if (wsRef.current) { + wsRef.current.disconnect(); + wsRef.current = null; + } + return; + } + + // 已登录:拿 token → 连 WS + const token = await getWsToken(authState.loginStatus); + if (cancelled || !token) return; + + // 清理旧连接(如有) + if (wsRef.current) { + wsRef.current.disconnect(); + wsRef.current = null; + } + + const ws = createChatWebSocket(token); + ws.onConnected = (userId) => { + chatDispatch({ type: "ChatWebSocketConnected", userId }); + }; + ws.onSentence = (p) => { + chatDispatch({ + type: "ChatAISentenceReceived", + index: p.index, + text: p.text, + total: p.total, + done: p.done, + }); + }; + ws.onError = (msg) => { + chatDispatch({ type: "ChatWebSocketError", errorMessage: msg }); + }; + ws.connect(); + wsRef.current = ws; + })(); + + return () => { + cancelled = true; + if (wsRef.current) { + wsRef.current.disconnect(); + wsRef.current = null; + } + }; + }, [authState.loginStatus, chatDispatch]); + + // ───────────────────────────────────────────────────────────── + // 副作用 ②:loginStatus 变化 → 重新加载 + // + // 规则: + // - 首次 mount(prev === null)→ 由 mount useEffect 统一处理 + // - "notLoggedIn" → 不加载 + // - 登录态变化(guest→email 等)→ 重新加载 + // - 游客派 ChatInit(拉本地 + **初始化配额**) + // - 非游客派 ChatLoadMoreHistory(拉服务器端首屏,**无**配额 init) + // ───────────────────────────────────────────────────────────── + const prevLoginStatusRef = useRef(null); + useEffect(() => { + const prev = prevLoginStatusRef.current; + prevLoginStatusRef.current = authState.loginStatus; + if (prev === null) return; // 首次 mount 由 mount useEffect 处理 + if (authState.loginStatus === "notLoggedIn") return; + if (prev !== authState.loginStatus) { + dispatchInitialLoad(authState.loginStatus, chatDispatch); + } + }, [authState.loginStatus, chatDispatch]); + + // ───────────────────────────────────────────────────────────── + // 配额耗尽触发器(**仅**游客 —— "非游客无消息限制") + // ───────────────────────────────────────────────────────────── const prevTriggerRef = useRef(state.quotaExceededTrigger); useEffect(() => { + if (!isGuest) return; // ← 防御:非游客 backend 不会发 ChatQuotaExceeded if ( state.quotaExceededTrigger !== prevTriggerRef.current && state.quotaExceededTrigger > 0 @@ -61,13 +209,13 @@ export function ChatScreen() { setQuotaDialogOpen(true); } prevTriggerRef.current = state.quotaExceededTrigger; - }, [state.quotaExceededTrigger]); + }, [state.quotaExceededTrigger, isGuest]); - // 配额警告(剩余 = 5) + // 配额警告(剩余 = 5)—— **仅**游客 const prevRemainingRef = useRef(state.guestRemainingQuota); useEffect(() => { if ( - state.isGuest && + isGuest && // ← 派生自 auth.loginStatus state.guestRemainingQuota === GuestChatQuota.warningThreshold && prevRemainingRef.current !== state.guestRemainingQuota ) { @@ -75,7 +223,7 @@ export function ChatScreen() { setQuotaDialogOpen(true); } prevRemainingRef.current = state.guestRemainingQuota; - }, [state.guestRemainingQuota, state.isGuest]); + }, [state.guestRemainingQuota, isGuest]); return ( @@ -92,12 +240,13 @@ export function ChatScreen() {
- + {/* isGuest 派生自 auth.loginStatus */} + @@ -109,7 +258,7 @@ export function ChatScreen() { {/* PWA 安装提示触发器(无可见 UI,3.5s 后弹 dialog) */} - {/* 配额告警弹窗 */} + {/* 配额告警弹窗(**仅**游客,gate 在 isGuest) */} setQuotaDialogOpen(false)} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 46350008..fdabae16 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,6 @@ import type { Metadata, Viewport } from "next"; import { SessionProvider } from "@/providers/session-provider"; -import { geistSans, geistMono, athelas } from "@/lib/fonts"; +import { geistSans, geistMono, athelas } from "@/core/fonts"; import "./globals.css"; import { RootProviders } from "@/providers/root-providers"; diff --git a/src/app/sidebar/components/sidebar-screen.tsx b/src/app/sidebar/components/sidebar-screen.tsx index 45da5a87..577a7ade 100644 --- a/src/app/sidebar/components/sidebar-screen.tsx +++ b/src/app/sidebar/components/sidebar-screen.tsx @@ -8,7 +8,6 @@ import { MobileShell } from "@/app/_components/core/mobile-shell"; import { SettingsSection } from "@/app/_components/core/settings-section"; import { useUserDispatch, useUserState } from "@/stores/user/user-context"; import { useAuthDispatch } from "@/stores/auth/auth-context"; -import { useChatDispatch } from "@/stores/chat/chat-context"; import { ROUTES } from "@/router/routes"; import type { UserView } from "@/models/user/user"; @@ -23,7 +22,6 @@ import styles from "./sidebar-screen.module.css"; export function SidebarScreen() { const user = useUserState(); const userDispatch = useUserDispatch(); - const chatDispatch = useChatDispatch(); const authDispatch = useAuthDispatch(); const router = useRouter(); @@ -34,17 +32,18 @@ export function SidebarScreen() { // 等价 Dart: BlocConsumer 的 listener(profile_view.dart:24-34) // 仅在 currentUser 由非 null 跳到 null 时触发清理 + // 注:chat 机器**不**再需要 `ChatAuthStatusChanged` 通知(已**删**)—— chat-screen + // 通过 `useAuthState()` 主动订阅 loginStatus;这里只需重置 auth + 跳 /chat const prevUserRef = useRef(user.currentUser); useEffect(() => { const wasLoggedIn = prevUserRef.current != null; const isNowLoggedOut = user.currentUser == null; prevUserRef.current = user.currentUser; if (wasLoggedIn && isNowLoggedOut) { - chatDispatch({ type: "ChatAuthStatusChanged" }); authDispatch({ type: "AuthReset" }); router.replace(ROUTES.chat); } - }, [user.currentUser, chatDispatch, authDispatch, router]); + }, [user.currentUser, authDispatch, router]); return ( diff --git a/src/app/splash/components/splash-screen.tsx b/src/app/splash/components/splash-screen.tsx index 57500549..92d7a1b1 100644 --- a/src/app/splash/components/splash-screen.tsx +++ b/src/app/splash/components/splash-screen.tsx @@ -5,7 +5,6 @@ import { useRouter } from "next/navigation"; import { MobileShell } from "@/app/_components/core/mobile-shell"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; -import { useChatDispatch } from "@/stores/chat/chat-context"; import { useUserDispatch } from "@/stores/user/user-context"; import { AuthStorage } from "@/data/storage/auth/auth_storage"; import { ROUTES } from "@/router/routes"; @@ -20,7 +19,6 @@ export function SplashScreen() { const router = useRouter(); const state = useAuthState(); const authDispatch = useAuthDispatch(); - const chatDispatch = useChatDispatch(); const userDispatch = useUserDispatch(); // ───────────────────────────────────────────────────────────── @@ -66,7 +64,6 @@ export function SplashScreen() { "[splash] useEffect ② → router.replace(/chat) [via pendingRedirect flag]", ); authDispatch({ type: "AuthClearPendingRedirect" }); - chatDispatch({ type: "ChatAuthStatusChanged" }); userDispatch({ type: "UserInit" }); router.replace(ROUTES.chat); } @@ -74,7 +71,6 @@ export function SplashScreen() { state.loginStatus, state.pendingRedirect, authDispatch, - chatDispatch, userDispatch, router, ]); diff --git a/src/hooks/use-breakpoint.ts b/src/hooks/use-breakpoint.ts index ef7b7387..63596a96 100644 --- a/src/hooks/use-breakpoint.ts +++ b/src/hooks/use-breakpoint.ts @@ -15,7 +15,7 @@ */ import { useSyncExternalStore } from "react"; -import type { DeviceSize } from "@/lib/breakpoints"; +import type { DeviceSize } from "@/core/breakpoints"; const QUERIES = { mobile: "(max-width: 599.98px)", diff --git a/src/stores/chat/chat-context.tsx b/src/stores/chat/chat-context.tsx index 2e0e6557..87105e40 100644 --- a/src/stores/chat/chat-context.tsx +++ b/src/stores/chat/chat-context.tsx @@ -8,8 +8,8 @@ * 兼容性:保持原 `useChatState()` / `useChatDispatch()` API。 * 内部将 XState 状态机快照映射回原 `ChatState` 形状。 * - * 注:WebSocket 长生命周期 actor 暂未集成到状态机(保留在 chat-side-effects.ts - * 由外部 chat-side-effects 模块管理),待下一轮迁移。 + * 注:WebSocket 长生命周期 actor **由 chat-screen.tsx 监听 auth 状态机驱动**—— + * 鉴权解耦(loginStatus 在 auth,chat 机器**不**感知鉴权)。 */ import { type Dispatch, @@ -24,13 +24,17 @@ import { chatMachine } from "./chat-machine"; import type { ChatEvent, ChatState as MachineContext } from "./chat-machine"; /** - * 对外暴露的 State 形状(保持与原 ChatState 兼容) + * 对外暴露的 State 形状 + * + * **isGuest 字段已删** —— 由 chat-screen 派生自 `auth.loginStatus === "guest"`。 + * 消费方(chat-screen)通过 `useAuthState()` 取 loginStatus,本地派生 isGuest。 */ interface ChatState { messages: MachineContext["messages"]; isReplyingAI: boolean; - isGuest: boolean; + /** 游客剩余配额(次/天)—— 业务展示用,鉴权判断由 chat-screen 派生 loginStatus */ guestRemainingQuota: number; + /** 游客总配额 */ guestTotalQuota: number; quotaExceededTrigger: number; isLoadingMore: boolean; @@ -53,7 +57,6 @@ export function ChatProvider({ children }: ChatProviderProps) { () => ({ messages: state.context.messages, isReplyingAI: state.context.isReplyingAI, - isGuest: state.context.isGuest, guestRemainingQuota: state.context.guestRemainingQuota, guestTotalQuota: state.context.guestTotalQuota, quotaExceededTrigger: state.context.quotaExceededTrigger, diff --git a/src/stores/chat/chat-events.ts b/src/stores/chat/chat-events.ts index 3cc507d2..2ac43d47 100644 --- a/src/stores/chat/chat-events.ts +++ b/src/stores/chat/chat-events.ts @@ -2,6 +2,10 @@ * Chat 状态机:事件联合 * * 事件名与原 Dart ChatEvent 对齐。 + * + * 历史:原本有 `ChatAuthStatusChanged`(chat 机器刷新 isGuest)—— 已**删**。 + * 鉴权状态变化(loginStatus)由 chat-screen 通过 `useAuthState()` 订阅, + * chat 机器**不**感知鉴权。 */ export type ChatEvent = | { type: "ChatInit" } @@ -19,5 +23,4 @@ export type ChatEvent = } | { type: "ChatWebSocketError"; errorMessage: string } | { type: "ChatWebSocketConnected"; userId: string } - | { type: "ChatQuotaExceeded" } - | { type: "ChatAuthStatusChanged" }; + | { type: "ChatQuotaExceeded" }; diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index 20940fa0..6b8db701 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -13,6 +13,16 @@ * - HTTP 操作用 `fromPromise` actor(一次性 Promise) * - 保持事件类型名与原 Dart 一致,便于业务层迁移对照 * - actions 全部 inline 在 setup() 中(确保类型推断正确) + * + * **鉴权解耦**: + * chat 机器**不**感知鉴权状态 —— `isGuest` 字段已删(由 chat-screen 派生自 auth.loginStatus)。 + * WebSocket 管理也由 chat-screen 监听 auth 状态机后驱动。 + * + * **配额(**仅**游客)**: + * - `guestRemainingQuota` / `guestTotalQuota` **仅**游客有意义 + * - 非游客"无消息限制"(业务事实)—— 永不被赋值 + * - chat-screen 派 `ChatInit` 时(**只**在 guest)→ onDone 条件赋 + * - `appendUserMessage` 减 `Math.max(0, q - 1)` —— 非游客 0-1=0 天然 no-op */ import { setup, fromPromise, assign } from "xstate"; @@ -85,6 +95,8 @@ interface InitResult { async function readInitData(): Promise { const chatStorage = ChatStorage.getInstance(); + // isGuest 在 chat 业务层仅用于"是否加载本地历史" + "onDone 条件赋配额" —— **不**是鉴权信号 + // 鉴权判断已上提到 chat-screen 派生 const isGuest = !AuthStorage.getInstance().hasLoginToken(); const [dailyResult, totalResult, localResult] = await Promise.all([ @@ -95,13 +107,14 @@ async function readInitData(): Promise { return { isGuest, + // fallback 0 —— 非游客路径用不到;onDone 会**条件**赋 guestRemainingQuota: mapQuotaResult( dailyResult as Result<{ remaining: number } | null>, - 40, + 0, ), guestTotalQuota: mapTotalQuotaResult( totalResult as Result, - 50, + 0, ), localMessages: localResult && Result.isOk(localResult) && localResult.data @@ -145,8 +158,7 @@ const loadMoreHistoryActor = fromPromise< }; }); -// ============================================================ -// Machine +// ============================================================// Machine // ============================================================ export const chatMachine = setup({ types: { @@ -171,6 +183,8 @@ export const chatMachine = setup({ }, ], isReplyingAI: true, + // 游客减配额;非游客 0 - 1 = max(0, -1) = 0 —— **天然 no-op**(业务事实"非游客无限制") + guestRemainingQuota: Math.max(0, context.guestRemainingQuota - 1), }; }), @@ -187,6 +201,8 @@ export const chatMachine = setup({ }, ], isReplyingAI: true, + // 游客减配额;非游客 no-op + guestRemainingQuota: Math.max(0, context.guestRemainingQuota - 1), }; }), @@ -223,10 +239,6 @@ export const chatMachine = setup({ return { messages, isReplyingAI: false }; }), - refreshAuthStatus: assign(() => ({ - isGuest: !AuthStorage.getInstance().hasLoginToken(), - })), - incrementQuotaExceeded: assign(({ context }) => ({ quotaExceededTrigger: context.quotaExceededTrigger + 1, })), @@ -248,9 +260,6 @@ export const chatMachine = setup({ ChatSendImage: { actions: "appendUserImage", }, - ChatAuthStatusChanged: { - actions: "refreshAuthStatus", - }, ChatAISentenceReceived: { actions: "appendOrUpdateAISentence", }, @@ -269,11 +278,18 @@ export const chatMachine = setup({ src: "chatInit", onDone: { target: "ready", - actions: assign({ - messages: ({ event }) => event.output.localMessages, - isGuest: ({ event }) => event.output.isGuest, - guestRemainingQuota: ({ event }) => event.output.guestRemainingQuota, - guestTotalQuota: ({ event }) => event.output.guestTotalQuota, + // **条件赋**:仅 isGuest 时初始化配额(业务事实"非游客无消息限制") + // - 游客:messages + quota 都更新 + // - 非游客:仅 messages(local 空),quota 保持 default 0(不更新其值) + actions: assign(({ event }) => { + const updates: Partial = { + messages: event.output.localMessages, + }; + if (event.output.isGuest) { + updates.guestRemainingQuota = event.output.guestRemainingQuota; + updates.guestTotalQuota = event.output.guestTotalQuota; + } + return updates; }), }, onError: { @@ -293,9 +309,6 @@ export const chatMachine = setup({ }, ChatLoadMoreHistory: "loadingMoreHistory", ChatScreenInvisible: "idle", - ChatAuthStatusChanged: { - actions: "refreshAuthStatus", - }, ChatAISentenceReceived: { actions: "appendOrUpdateAISentence", }, diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts index ed204a32..cbb2029b 100644 --- a/src/stores/chat/chat-state.ts +++ b/src/stores/chat/chat-state.ts @@ -2,14 +2,26 @@ * Chat 状态机:State 形状 + 初始值 * * 注:GuestChatQuota 仍位于 chat-machine.ts(属于业务常量,与上下文配对紧密)。 + * + * 历史: + * - `isGuest: boolean` 字段已**删**(由 chat-screen 派生自 `auth.loginStatus === "guest"`) + * - `guestRemainingQuota` / `guestTotalQuota` default **改 0**: + * - 游客:chat-screen 派 `ChatInit` → readInitData 调 ChatStorage → onDone 条件赋 + * - 非游客:**永不被赋值**(业务事实"非游客无消息限制")—— 0 - 1 = max(0, -1) = 0 天然 no-op + * - 0 同时表示"未初始化"和"已耗尽" —— UI 用 `if (isGuest && ...)` 天然过滤 */ import type { UiMessage } from "@/models/chat/ui-message"; export interface ChatState { messages: UiMessage[]; isReplyingAI: boolean; - isGuest: boolean; + /** + * 游客剩余配额(次/天)—— **仅**游客业务展示用 + * - default 0 = "未初始化" / "已耗尽"(同码,靠 `isGuest` 区分) + * - 非游客:永不被赋值(业务事实"无消息限制") + */ guestRemainingQuota: number; + /** 游客总配额(仅游客展示用) */ guestTotalQuota: number; quotaExceededTrigger: number; isLoadingMore: boolean; @@ -20,9 +32,8 @@ export interface ChatState { export const initialState: ChatState = { messages: [], isReplyingAI: false, - isGuest: true, - guestRemainingQuota: 40, - guestTotalQuota: 50, + guestRemainingQuota: 0, + guestTotalQuota: 0, quotaExceededTrigger: 0, isLoadingMore: false, hasMore: true,