Merge branch 'dev' into test
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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 machine(chat 机器不感知 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,12 +2,10 @@
|
|||||||
/**
|
/**
|
||||||
* ChatScreen 编排层
|
* ChatScreen 编排层
|
||||||
*
|
*
|
||||||
* 原始 Dart: lib/ui/chat/chat_screen.dart(172 行)
|
|
||||||
*
|
|
||||||
* 职责(本文件):
|
* 职责(本文件):
|
||||||
* - **订阅 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 @@
|
|||||||
* - PwaInstallOverlay:PWA 安装提示触发器
|
* - PwaInstallOverlay:PWA 安装提示触发器
|
||||||
* - 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") {
|
||||||
// 游客:派 ChatGuestLogin(chat 机器**不**连 WS —— 业务事实"断 WS")
|
// 游客:派 ChatGuestLogin(chat 机器不连 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 就跳 banner(loadQuota 还在 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>
|
||||||
|
|
||||||
{/* 浏览器提示(应用内浏览器检测) */}
|
{/* 浏览器提示(应用内浏览器检测) */}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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, ...)`,
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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>>;
|
||||||
|
|
||||||
|
|||||||
@@ -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()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -1,4 +1,4 @@
|
|||||||
// /**
|
// /
|
||||||
// * Router 公共导出
|
// * Router 公共导出
|
||||||
// *
|
// *
|
||||||
// * 集中管理:
|
// * 集中管理:
|
||||||
|
|||||||
+8
-8
@@ -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 仍会跑"
|
||||||
// * (安全兜底)。
|
// * (安全兜底)。
|
||||||
|
|||||||
@@ -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)使用 —— 不能整体注释。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** 静态路由字面量 */
|
/** 静态路由字面量 */
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export const syncFacebookBackendActor = fromPromise<
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ========== 显式游客登录 actor(opt-in) ==========
|
// ========== 显式游客登录 actor(opt-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();
|
||||||
|
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 在 auth,chat 机器**不**感知鉴权)。
|
|
||||||
*/
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 —— 拉服务器端首屏 + **连** WS(token 在 payload)
|
* - `ChatNonGuestLogin`:非游客进入 /chat —— 拉服务器端首屏 + 连 WS(token 在 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;
|
||||||
|
|||||||
@@ -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)
|
||||||
* - 选**方**案 A(fromPromise):UI **不**会**看**到** local 中**间**态**,**直**接**显**示** network **结**果**
|
* - 选方案 A(fromPromise):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 时自动调用
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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 依赖 helpers(import)
|
* - actors 依赖 helpers(import)
|
||||||
* - machine 依赖 actors(import)—— **不**直接依赖 helpers
|
* - machine 依赖 actors(import)—— 不直接依赖 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 任务 2:history 3 步流(local → network → save)—— `loadHistoryActor` 调
|
// Init 任务 2:history 3 步流(local → network → save)—— `loadHistoryActor` 调
|
||||||
// ============================================================
|
// ============================================================
|
||||||
/**
|
/**
|
||||||
* 拉 history **流**程(**每**步**都**记**日**志** —— 用户要求"**不**要**删**除**")
|
* 拉 history 流程(每步都记日志 —— 用户要求"不要删除")
|
||||||
* 1. **读** local(chatRepo.getLocalMessages)→ **返** localMessages
|
* 1. 读 local(chatRepo.getLocalMessages)→ 返 localMessages
|
||||||
* 2. **读** network(chatRepo.getHistory)→ **返** networkMessages
|
* 2. 读 network(chatRepo.getHistory)→ 返 networkMessages
|
||||||
* 3. **用** network **覆**盖** local(chatRepo.saveMessagesToLocal)→ local **被**同步
|
* 3. 用 network 覆盖 local(chatRepo.saveMessagesToLocal)→ local 被同步
|
||||||
*
|
*
|
||||||
* **返**回** network **最**终** messages**(authorititative),**不**返** local(UI **不**需**要**过渡**态**)
|
* 返回 network 最终 messages(authorititative),不返 local(UI 不需要过渡态)
|
||||||
*
|
*
|
||||||
* **保**留**日**志**(**前**缀 `[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 **最**终** messages(authorititative)
|
messages: UiMessage[]; // network 最终 messages(authorititative)
|
||||||
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", {
|
||||||
|
|||||||
@@ -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.initializing:loadQuota + loadHistory **并**行**(`invoke: [...]` **数**组**)
|
* - guestSession.initializing:loadQuota + loadHistory 并行(`invoke: [...]` 数组)
|
||||||
* - userSession.initializing:loadHistory + parent-level chatWebSocket **并**行
|
* - userSession.initializing:loadHistory + 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(游客会话 —— **不**连 WS,2 **个** init 任务**并**行**)
|
// guestSession(游客会话 —— 不连 WS,2 个 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:拉 history(fromPromise,**返**回**最**终** network messages**)
|
// 任务 2:拉 history(fromPromise,返回最终 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: {
|
||||||
// barrier:WS **连**上** + history **加**载**完**成**才**进** ready
|
// barrier:WS 连上 + 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,
|
||||||
|
|||||||
@@ -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` → false(WS actor cleanup 时**也**会跑 exit)
|
* - `userSession.exit` → false(WS 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,12 @@
|
|||||||
*/
|
*/
|
||||||
@theme {
|
@theme {
|
||||||
/* 字体族 */
|
/* 字体族 */
|
||||||
--font-system: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
/*
|
||||||
Roboto, "Helvetica Neue", Arial, sans-serif;
|
* --font-system 优先用 Athelas(Apple 平台专用字体 — 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
@@ -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>> {
|
||||||
|
|||||||
Reference in New Issue
Block a user