diff --git a/.claude/skills/git-commit/SKILL.md b/.claude/skills/git-commit/SKILL.md new file mode 100644 index 00000000..c35f13b8 --- /dev/null +++ b/.claude/skills/git-commit/SKILL.md @@ -0,0 +1,124 @@ +--- +name: git-commit +description: 'Execute git commit with conventional commit message analysis, intelligent staging, and message generation. Use when user asks to commit changes, create a git commit, or mentions "/commit". Supports: (1) Auto-detecting type and scope from changes, (2) Generating conventional commit messages from diff, (3) Interactive commit with optional type/scope/description overrides, (4) Intelligent file staging for logical grouping' +license: MIT +allowed-tools: Bash +--- + +# Git Commit with Conventional Commits + +## Overview + +Create standardized, semantic git commits using the Conventional Commits specification. Analyze the actual diff to determine appropriate type, scope, and message. + +## Conventional Commit Format + +``` +[optional scope]: + +[optional body] + +[optional footer(s)] +``` + +## Commit Types + +| Type | Purpose | +| ---------- | ------------------------------ | +| `feat` | New feature | +| `fix` | Bug fix | +| `docs` | Documentation only | +| `style` | Formatting/style (no logic) | +| `refactor` | Code refactor (no feature/fix) | +| `perf` | Performance improvement | +| `test` | Add/update tests | +| `build` | Build system/dependencies | +| `ci` | CI/config changes | +| `chore` | Maintenance/misc | +| `revert` | Revert commit | + +## Breaking Changes + +``` +# Exclamation mark after type/scope +feat!: remove deprecated endpoint + +# BREAKING CHANGE footer +feat: allow config to extend other configs + +BREAKING CHANGE: `extends` key behavior changed +``` + +## Workflow + +### 1. Analyze Diff + +```bash +# If files are staged, use staged diff +git diff --staged + +# If nothing staged, use working tree diff +git diff + +# Also check status +git status --porcelain +``` + +### 2. Stage Files (if needed) + +If nothing is staged or you want to group changes differently: + +```bash +# Stage specific files +git add path/to/file1 path/to/file2 + +# Stage by pattern +git add *.test.* +git add src/components/* + +# Interactive staging +git add -p +``` + +**Never commit secrets** (.env, credentials.json, private keys). + +### 3. Generate Commit Message + +Analyze the diff to determine: + +- **Type**: What kind of change is this? +- **Scope**: What area/module is affected? +- **Description**: One-line summary of what changed (present tense, imperative mood, <72 chars) + +### 4. Execute Commit + +```bash +# Single line +git commit -m "[scope]: " + +# Multi-line with body/footer +git commit -m "$(cat <<'EOF' +[scope]: + + + + +EOF +)" +``` + +## Best Practices + +- One logical change per commit +- Present tense: "add" not "added" +- Imperative mood: "fix bug" not "fixes bug" +- Reference issues: `Closes #123`, `Refs #456` +- Keep description under 72 characters + +## Git Safety Protocol + +- NEVER update git config +- NEVER run destructive commands (--force, hard reset) without explicit request +- NEVER skip hooks (--no-verify) unless user asks +- NEVER force push to main/master +- If commit fails due to hooks, fix and create NEW commit (don't amend) diff --git a/src/app/auth/components/email-login-form.tsx b/src/app/auth/components/email-login-form.tsx index f0d067f7..094e58f5 100644 --- a/src/app/auth/components/email-login-form.tsx +++ b/src/app/auth/components/email-login-form.tsx @@ -1,15 +1,6 @@ "use client"; /** * 邮箱登录表单 - * - * 原始 Dart: lib/ui/auth/widgets/email_login_form.dart - * - * 视觉规格(与 Dart 对齐): - * - 字段无 label(仅 placeholder) - * - 错误仅以红色横幅显示(AuthErrorMessage),无字段级错误 - * - 不显示 "Forgot password?" 链接 - * - 切换链接由父级 AuthEmailPanel 渲染 - * - 提交派发 `AuthEmailLoginSubmitted` */ import { useState } from "react"; @@ -36,11 +27,41 @@ export function EmailLoginForm({ const dispatch = useAuthDispatch(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + // 本地校验错误(client-side)—— 校验失败时展示第一个非 null 的错误 + // 优先级:local validationError > server globalError + const [validationError, setValidationError] = useState(null); + + // 用户在任一字段开始输入 → 清掉旧校验错误(标准 UX:边改边消) + // 直接在 onChange 调 setState —— React useState 对相同值自动 bail-out 不触发额外渲染 + // 避免 useEffect 内 setState 的"级联渲染"反模式 + const onEmailChange = (v: string) => { + setEmail(v); + setValidationError(null); + }; + const onPasswordChange = (v: string) => { + setPassword(v); + setValidationError(null); + }; const submit = () => { + console.log("[email-login-form] submit ENTRY", { + emailLength: email.length, + emailPreview: email.slice(0, 8), + passwordLength: password.length, + }); const emailErr = validateEmail(email); const passwordErr = validatePassword(password); - if (emailErr || passwordErr) return; + if (emailErr || passwordErr) { + const firstErr = emailErr ?? passwordErr ?? null; + console.warn( + "[email-login-form] submit VALIDATION FAILED (silent return)", + { emailErr, passwordErr, surfaced: firstErr }, + ); + setValidationError(firstErr); + return; + } + setValidationError(null); + console.log("[email-login-form] submit dispatching AuthEmailLoginSubmitted"); dispatch({ type: "AuthEmailLoginSubmitted", email, password }); }; @@ -55,19 +76,20 @@ export function EmailLoginForm({ - + {/* 优先级:本地校验错误 > 后端 globalError(两个互斥场景:本地校验未过时,UI 不会再有 globalError) */} + Login diff --git a/src/app/auth/components/email-register-form.tsx b/src/app/auth/components/email-register-form.tsx index a47902f7..9a7c8c04 100644 --- a/src/app/auth/components/email-register-form.tsx +++ b/src/app/auth/components/email-register-form.tsx @@ -40,13 +40,64 @@ export function EmailRegisterForm({ const [password, setPassword] = useState(""); const [username, setUsername] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); + // 本地校验错误(client-side)—— 校验失败时展示第一个非 null 的错误 + // 优先级:local validationError > server globalError + const [validationError, setValidationError] = useState(null); + + // 用户在任一字段开始输入 → 清掉旧校验错误(标准 UX:边改边消) + // 直接在 onChange 调 setState —— React useState 对相同值自动 bail-out 不触发额外渲染 + // 避免 useEffect 内 setState 的"级联渲染"反模式 + const clearValidationOnType = () => setValidationError(null); + const onUsernameChange = (v: string) => { + setUsername(v); + clearValidationOnType(); + }; + const onEmailChange = (v: string) => { + setEmail(v); + clearValidationOnType(); + }; + const onPasswordChange = (v: string) => { + setPassword(v); + clearValidationOnType(); + }; + const onConfirmPasswordChange = (v: string) => { + setConfirmPassword(v); + clearValidationOnType(); + }; const submit = () => { + console.log("[email-register-form] submit ENTRY", { + emailLength: email.length, + emailPreview: email.slice(0, 8), + passwordLength: password.length, + usernameLength: username.length, + confirmPasswordLength: confirmPassword.length, + }); const emailErr = validateEmail(email); const passwordErr = validatePassword(password); const usernameErr = validateUsername(username); const confirmErr = validateConfirmPassword(password, confirmPassword); - if (emailErr || passwordErr || usernameErr || confirmErr) return; + if (emailErr || passwordErr || usernameErr || confirmErr) { + // 按 username → email → password → confirm 顺序挑第一个错(与字段顺序一致) + const firstErr = + usernameErr ?? emailErr ?? passwordErr ?? confirmErr ?? null; + console.warn( + "[email-register-form] submit VALIDATION FAILED (silent return)", + { + emailErr, + passwordErr, + usernameErr, + confirmErr, + surfaced: firstErr, + }, + ); + setValidationError(firstErr); + return; + } + setValidationError(null); + console.log( + "[email-register-form] submit dispatching AuthEmailRegisterSubmitted", + ); dispatch({ type: "AuthEmailRegisterSubmitted", email, @@ -66,33 +117,34 @@ export function EmailRegisterForm({ > - + {/* 优先级:本地校验错误 > 后端 globalError */} + Register diff --git a/src/models/auth/login-status.ts b/src/models/auth/login-status.ts index d00848bc..28e55524 100644 --- a/src/models/auth/login-status.ts +++ b/src/models/auth/login-status.ts @@ -1,17 +1,10 @@ /** * 登录状态枚举 - * - * 原始 Dart: lib/ui/auth/bloc/auth_state.dart 的 `LoginType` 枚举(v7.0 新增)。 - * - * 2025-XX 改造:语义从"登录方式"扩展为"当前登录状态"—— - * 包含 notLoggedIn / guest / 各 OAuth provider 登录后态。 - * - * 字段命名(state)从 `loginType` 改为 `loginStatus`,更贴合语义。 */ export const LoginStatus = { /** 未登录(默认初值) */ NotLoggedIn: "notLoggedIn", - /** 游客登录(无需账号,10 轮免费消息) */ + /** 游客登录 */ Guest: "guest", /** Facebook OAuth 登录 */ Facebook: "facebook", diff --git a/src/stores/auth/auth-actors.ts b/src/stores/auth/auth-actors.ts index 74e5a0c8..efae0378 100644 --- a/src/stores/auth/auth-actors.ts +++ b/src/stores/auth/auth-actors.ts @@ -15,18 +15,37 @@ import type { LoginStatus } from "@/models/auth/login-status"; import { authRepository } from "@/data/repositories/auth_repository"; import type { IAuthRepository } from "@/data/repositories/interfaces"; import { AuthStorage } from "@/data/storage/auth/auth_storage"; +import { UserStorage } from "@/data/storage/user/user_storage"; import { deviceIdentifier } from "@/utils/device_identifier"; +import { fetchFacebookUserData } from "@/utils/facebook-graph"; +import { Logger } from "@/utils/logger"; import { Result } from "@/utils/result"; import { readGuestId } from "./auth-helpers"; +const log = new Logger("AuthActors"); + // 仓库以接口类型注入:调用面只看接口,运行时仍是同一单例 const authRepo: IAuthRepository = authRepository; export const emailLoginActor = fromPromise( async ({ input }) => { + console.log("[auth-machine] emailLoginActor ENTRY", { + emailLength: input.email.length, + emailPreview: input.email.slice(0, 8), + passwordLength: input.password.length, + }); const guestId = await readGuestId(); + console.log("[auth-machine] emailLoginActor calling authRepo.emailLogin", { + hasGuestId: !!guestId, + }); const result = await authRepo.emailLogin({ ...input, guestId }); + console.log("[auth-machine] emailLoginActor authRepo.emailLogin DONE", { + success: result.success, + hasData: result.success ? !!result.data : null, + errorName: result.success ? null : result.error?.name, + errorMessage: result.success ? null : result.error?.message, + }); if (Result.isErr(result)) throw result.error; return "email" as LoginStatus; }, @@ -36,17 +55,40 @@ export const emailRegisterThenLoginActor = fromPromise< LoginStatus, { email: string; password: string; username: string; confirmPassword: string } >(async ({ input }) => { + console.log("[auth-machine] emailRegisterThenLoginActor ENTRY", { + emailLength: input.email.length, + usernameLength: input.username.length, + passwordLength: input.password.length, + confirmPasswordLength: input.confirmPassword.length, + }); const guestId = await readGuestId(); + console.log("[auth-machine] emailRegisterThenLoginActor calling authRepo.register"); const registerResult = await authRepo.register({ ...input, guestId }); + console.log("[auth-machine] emailRegisterThenLoginActor authRepo.register DONE", { + success: registerResult.success, + errorName: registerResult.success ? null : registerResult.error?.name, + errorMessage: registerResult.success ? null : registerResult.error?.message, + }); if (Result.isErr(registerResult)) throw registerResult.error; // 注册后自动登录(对齐 Dart 行为) + console.log( + "[auth-machine] emailRegisterThenLoginActor calling authRepo.emailLogin (post-register)", + ); const loginResult = await authRepo.emailLogin({ email: input.email, password: input.password, guestId, }); + console.log( + "[auth-machine] emailRegisterThenLoginActor authRepo.emailLogin DONE", + { + success: loginResult.success, + errorName: loginResult.success ? null : loginResult.error?.name, + errorMessage: loginResult.success ? null : loginResult.error?.message, + }, + ); if (Result.isErr(loginResult)) throw loginResult.error; return "email" as LoginStatus; }); @@ -89,9 +131,70 @@ export const syncFacebookBackendActor = fromPromise< guestId, }); if (Result.isErr(result)) throw result.error; + + // 业务 token 已到手 —— 顺带用同一个 accessToken 调 Facebook Graph `/me` + // 拉用户资料(id / name / email / picture),把 pictureUrl + Facebook ID + // 写本地。 + // + // 为什么要这一步?后端 LoginResponse.user.avatarUrl 不一定总有(依赖 + // 后端在 OAuth 流程里有没有抓过);前端用 accessToken 直接调 Graph + // 拿到的 picture 是最新的,独立于后端缓存。 + // + // 失败策略:best-effort。Graph 调用挂掉 / 用户没头像 / token 过期都不 + // 影响 Facebook 登录主流程 —— 业务 token 已经在 result 里。 + await persistFacebookProfile(input.accessToken); + return "facebook" as LoginStatus; }); +/** + * 用 accessToken 调 Facebook Graph `/me` 拉用户资料,写本地: + * - `id` → `AuthStorage.setFacebookId()`(独立于后端 userId) + * - `pictureUrl` → `UserStorage.setAvatarUrl()`(头像专用 slot,便于快速读取) + * + * 全部 best-effort:单条失败只 warn,不抛错。`pictureUrl == null` 也算成功 + * (用户没设头像),只是不写入。 + */ +async function persistFacebookProfile(accessToken: string): Promise { + const fbR = await fetchFacebookUserData(accessToken); + if (Result.isErr(fbR)) { + log.warn( + { err: fbR.error.message }, + "[auth-machine] syncFacebookBackendActor: fetchFacebookUserData failed (continuing anyway)", + ); + return; + } + const fbUser = fbR.data; + log.info( + { + hasId: !!fbUser.id, + hasPicture: !!fbUser.pictureUrl, + }, + "[auth-machine] syncFacebookBackendActor: Facebook profile fetched", + ); + + const authStorage = AuthStorage.getInstance(); + if (fbUser.id) { + const r = await authStorage.setFacebookId(fbUser.id); + if (!r.success) { + log.warn( + { err: r.error.message }, + "[auth-machine] syncFacebookBackendActor: setFacebookId failed", + ); + } + } + + if (fbUser.pictureUrl) { + const r = await UserStorage.getInstance().setAvatarUrl(fbUser.pictureUrl); + if (!r.success) { + log.warn( + { err: r.error.message }, + "[auth-machine] syncFacebookBackendActor: setAvatarUrl failed", + ); + } + } +} + // ========== 显式游客登录 actor(opt-in) ========== // 由 splash UI 派发 `AuthGuestLoginSubmitted` 触发,不自动跑。 // 取代了之前 checkAuthStatusActor 的 auto-guest-login 行为 —— 避免每次 diff --git a/src/stores/auth/auth-machine.ts b/src/stores/auth/auth-machine.ts index baa9b644..bc0b1df4 100644 --- a/src/stores/auth/auth-machine.ts +++ b/src/stores/auth/auth-machine.ts @@ -83,21 +83,21 @@ export const authMachine = setup({ }, // 启动 / 恢复时检查登录态 —— 由 派发 AuthStatusCheckSubmitted: "checkingAuthStatus", - // 显式游客登录 —— 由 splash UI 派发(不自动) + AuthGuestLoginSubmitted: "loadingGuestLogin", AuthEmailLoginSubmitted: "loadingEmailLogin", AuthEmailRegisterSubmitted: "loadingEmailRegister", AuthGoogleLoginSubmitted: "loadingOAuth", AuthFacebookLoginSubmitted: "loadingOAuth", - - // OAuth 回调后 sync 入口 —— 由 派发 - AuthGoogleSyncSubmitted: "syncingGoogleBackend", - AuthFacebookSyncSubmitted: "syncingFacebookBackend", AuthAppleLoginSubmitted: { actions: assign({ errorMessage: "Apple login not implemented", }), }, + + // OAuth 回调后 sync 入口 —— 由 派发 + AuthGoogleSyncSubmitted: "syncingGoogleBackend", + AuthFacebookSyncSubmitted: "syncingFacebookBackend", // splash / auth screen 跳完 /chat 后清flag AuthClearPendingRedirect: { actions: assign({ pendingRedirect: false }), @@ -151,6 +151,7 @@ export const authMachine = setup({ }, loadingEmailLogin: { + entry: assign({ errorMessage: null }), invoke: { src: "emailLogin", input: ({ event }) => { @@ -177,6 +178,7 @@ export const authMachine = setup({ }, loadingEmailRegister: { + entry: assign({ errorMessage: null }), invoke: { src: "emailRegisterThenLogin", input: ({ event }) => { diff --git a/src/stores/auth/auth-status-checker.tsx b/src/stores/auth/auth-status-checker.tsx index 1a250b75..03433049 100644 --- a/src/stores/auth/auth-status-checker.tsx +++ b/src/stores/auth/auth-status-checker.tsx @@ -1,25 +1,74 @@ "use client"; /** - * AuthStatusChecker 启动检查器 + * AuthStatusChecker 启动 + 状态变化检查器 * - * App 启动时派发一次 `AuthStatusCheckSubmitted`, - * 让 auth machine 走"拿 deviceId → 查 token → 必要时游客登录"流程。 + * 触发时机: + * 1. App 启动时(mount)派发一次 `AuthStatusCheckSubmitted` + * 2. loginStatus 每次变化时(外部触发)也派发一次 —— 重新读 storage 校准 + * 例:OAuth sync 完成 → loginStatus 变 "facebook" → re-check 一次确认 storage 一致 + * 邮箱登录成功 → loginStatus 变 "email" → re-check 确认 token 已落盘 + * + * 死循环防护(关键 —— 不加会无限递归): + * - 状态机收 `AuthStatusCheckSubmitted` → 进 `checkingAuthStatus` → 读 storage → onDone 写回 loginStatus + * - 如果 useEffect 直接依赖 `loginStatus`,写回本身触发 effect → 再 dispatch → 死循环 + * - 用 `skipNextChangeRef` 抑制一次: + * 每次我们自己 dispatch 前把 flag 置 true; + * effect 见到 loginStatus 变化且 flag 为 true 时跳过并清 flag。 * * 与 平级: - * - AuthStatusChecker: 一次性 init - * - OAuthSessionSync: 持续监听 NextAuth session + * - AuthStatusChecker: 启动 + 状态变化时 re-check("自己也能感知") + * - OAuthSessionSync: 持续监听 NextAuth session("别人也在写") */ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; -import { useAuthDispatch } from "./auth-context"; +import type { LoginStatus } from "@/models/auth/login-status"; + +import { useAuthDispatch, useAuthState } from "./auth-context"; export function AuthStatusChecker() { const dispatch = useAuthDispatch(); + const loginStatus = useAuthState().loginStatus; + // 上一次的 loginStatus(首次 mount 留 null —— 用来跳过首次 effect 跑) + const prevLoginStatusRef = useRef(null); + // 抑制下一次 loginStatus 变化触发的 effect(自己 dispatch 引起的写回要跳过) + const skipNextChangeRef = useRef(false); + + // ───────────────────────────────────────────────────────────── + // ① App 启动时:派发一次 + // ───────────────────────────────────────────────────────────── useEffect(() => { + console.log("[auth-status-checker] mount → dispatch AuthStatusCheckSubmitted"); + skipNextChangeRef.current = true; // 抑制启动 check 写回 loginStatus 引起的 effect dispatch({ type: "AuthStatusCheckSubmitted" }); }, [dispatch]); + // ───────────────────────────────────────────────────────────── + // ② loginStatus 变化时:派发一次(但跳过我们自己 check 写回的那次) + // ───────────────────────────────────────────────────────────── + useEffect(() => { + const prev = prevLoginStatusRef.current; + prevLoginStatusRef.current = loginStatus; + if (prev === null) return; // 首次 mount —— 启动那次由 ① 处理 + if (prev === loginStatus) return; // loginStatus 没变 —— 不触发 + if (skipNextChangeRef.current) { + // 这是我们自己 check 写回的 —— 抑制本次 + skipNextChangeRef.current = false; + console.log( + "[auth-status-checker] loginStatus change suppressed (self-triggered)", + { from: prev, to: loginStatus }, + ); + return; + } + // 外部触发(OAuth sync / 邮箱登录 / 登出 等)的 loginStatus 变化 → 再 check 一次校准 + skipNextChangeRef.current = true; + console.log( + "[auth-status-checker] loginStatus changed externally → re-check", + { from: prev, to: loginStatus }, + ); + dispatch({ type: "AuthStatusCheckSubmitted" }); + }, [loginStatus, dispatch]); + // 无 UI return null; -} +} \ No newline at end of file diff --git a/src/stores/chat/chat-machine.actors.ts b/src/stores/chat/chat-machine.actors.ts index df910fb9..d5c7bbb7 100644 --- a/src/stores/chat/chat-machine.actors.ts +++ b/src/stores/chat/chat-machine.actors.ts @@ -17,7 +17,7 @@ import { fromPromise, fromCallback } from "xstate"; -import { createChatWebSocket } from "@/core/net/chat-websocket"; +import { createChatWebSocket, ChatWebSocket } from "@/core/net/chat-websocket"; import { Result } from "@/utils/result"; import { @@ -30,6 +30,28 @@ import { } from "./chat-machine.helpers"; import type { ChatEvent } from "./chat-events"; +// ============================================================ +// 共享 WS 引用(chatWebSocketActor ↔ sendMessageWsActor) +// ============================================================ +/** + * 活跃 ChatWebSocket 单例引用(module-level)。 + * - `chatWebSocketActor` 在 connect 后写入、cleanup 时清空 + * - `sendMessageWsActor` 读取并调 `ws.sendMessage(content)` + * + * 为什么用 module-level 而非 context.wsRef: + * 1) `ChatWebSocket` 是带回调 + 重连 timer 的非可序列化对象,放进 + * XState context 会污染快照、警告 non-serializable + * 2) userSession 同一时间只可能有一个活跃 WS(parent-level invoke), + * module 单例的语义和"userSession 内一个活跃连接"一一对应 + * 3) 不需要重构 ChatWebSocket 类的对外 API + */ +let activeChatWebSocket: ChatWebSocket | null = null; + +/** 暴露给 `sendMessageWsActor` 读取(测试也可 import) */ +export function getActiveChatWebSocket(): ChatWebSocket | null { + return activeChatWebSocket; +} + // ============================================================ // Init 任务 1:拉游客配额(fromPromise,纯 local) // ============================================================ @@ -196,6 +218,13 @@ export const chatWebSocketActor = fromCallback( console.log("[chat-machine] chatWebSocketActor createChatWebSocket DONE", { wsType: ws.constructor.name, }); + + // 写入 module-level 共享引用 —— sendMessageWsActor 借此调 ws.sendMessage() + activeChatWebSocket = ws; + console.log("[chat-machine] chatWebSocketActor activeChatWebSocket SET", { + isConnected: ws.isConnected, + }); + ws.onConnected = (userId) => { console.log("[chat-machine] WS onConnected", { userId, @@ -234,10 +263,62 @@ export const chatWebSocketActor = fromCallback( console.log( "[chat-machine] chatWebSocketActor CLEANUP (parent state exit / logout / cross-transition / unmount)", ); + // 清空 module-level 引用 —— 严格用 `=== ws` 判等,避免覆盖更新的实例 + if (activeChatWebSocket === ws) { + activeChatWebSocket = null; + console.log("[chat-machine] chatWebSocketActor activeChatWebSocket CLEARED"); + } ws.disconnect(); }; }, ); +// ============================================================ +// WebSocket: per-send callback +// ============================================================ +/** + * WS 发送消息(userSession.sendingViaWs 期间 invoke) + * - 走 `activeChatWebSocket.sendMessage(content)` + * - 成功后 AI 回复通过 `chatWebSocketActor` 的 `onSentence` → `ChatAISentenceReceived` 流回 + * - 失败抛错 → state machine 的 `onError` 兜底(清 isReplyingAI、回 ready) + * + * 与 sendMessageHttpActor 的区别: + * - sendMessageHttpActor: 一次发送 = 一次返回(HTTP response = AI reply) + * - sendMessageWsActor: 一次发送 = 0 次直接返回(WS 异步流式回 reply,actor 本身不返 reply) + * + * 选 fromPromise 而非 fromCallback: + * - 本 actor 是 fire-and-forget —— sync 内同步执行 ws.sendMessage,立即 resolve + * - actor 本身没有"完成"语义(done 来自 WS 事件流,不来自这个 actor) + * - 抛错走 onError 通道 + * - fromPromise 的 input type 推断比 fromCallback 稳定(XState v5 已知坑) + */ +export const sendMessageWsActor = fromPromise( + async ({ input }) => { + console.log("[chat-machine] sendMessageWsActor ENTRY", { + contentLength: input.content.length, + contentPreview: input.content.slice(0, 50), + }); + const ws = getActiveChatWebSocket(); + if (!ws) { + console.error("[chat-machine] sendMessageWsActor no activeChatWebSocket"); + throw new Error("[chat-machine] sendMessageWs: no active WebSocket (actor not running)"); + } + console.log("[chat-machine] sendMessageWsActor calling ws.sendMessage", { + isConnected: ws.isConnected, + }); + const ok = ws.sendMessage(input.content); + if (!ok) { + console.error( + "[chat-machine] sendMessageWsActor ws.sendMessage returned false (not OPEN)", + ); + throw new Error("[chat-machine] sendMessageWs: WebSocket not OPEN"); + } + console.log( + "[chat-machine] sendMessageWsActor ws.sendMessage OK, awaiting AI sentence stream", + ); + // resolve 不返值 —— AI reply 由 chatWebSocketActor 的 ChatAISentenceReceived 流回 + }, +); + // Re-export `UiMessage` type for the chat-machine.ts public API type UiMessage = import("@/models/chat/ui-message").UiMessage; diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index e56d66a5..8e6b6816 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -42,6 +42,7 @@ import { loadQuotaActor, loadHistoryActor, sendMessageHttpActor, + sendMessageWsActor, loadMoreHistoryActor, chatWebSocketActor, } from "./chat-machine.actors"; @@ -69,6 +70,7 @@ export const chatMachine = setup({ loadQuota: loadQuotaActor, loadHistory: loadHistoryActor, sendMessageHttp: sendMessageHttpActor, + sendMessageWs: sendMessageWsActor, loadMoreHistory: loadMoreHistoryActor, chatWebSocket: chatWebSocketActor, }, @@ -344,10 +346,23 @@ export const chatMachine = setup({ // userSession(非游客会话 —— WS 父级 + history 子级) // ======================================================================== userSession: { + // 父级 on:把 WS / AI 流句 事件从 ready.on 上提到 userSession.on + // —— 任何 child state(initializing / ready / sendingViaWs / sendingViaHttp / loadingMore) + // 都能收到 `ChatAISentenceReceived` 并把 AI 句 push 到 messages, + // 这点是 sendingViaWs 流式回 reply 的关键(machine 当时在 sendingViaWs) + // + // 子级 override 规则: + // - sendingViaWs 重新声明 `ChatWebSocketError` 加 target: "ready"(带 action) + // —— XState v5: child handler 替换 parent;复制 action + on: { + ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" }, + ChatWebSocketError: { actions: "appendSocketErrorMessage" }, + ChatWebSocketConnected: { actions: "setWsConnected" }, + }, initial: "initializing", exit: "clearWsConnected", invoke: { - // 父级:WS 长连接(一进来就连,跨 initializing/ready/sending/loadingMore) + // 父级:WS 长连接(一进来就连,跨 initializing/ready/sending*/loadingMore) src: "chatWebSocket", input: ({ event }) => ({ token: event.type === "ChatNonGuestLogin" ? event.token : "", @@ -385,11 +400,24 @@ export const chatMachine = setup({ }, ready: { on: { - ChatSendMessage: { - actions: "appendUserMessage", - guard: ({ event }) => event.content.trim().length > 0, - target: "sending", - }, + // 发送消息:wsConnected → 走 WS(流式回 reply),否则 → 走 HTTP(一次性回 reply) + // 两个 branch 都有 "content 非空" guard(仅作 fallback 兜底:wsConnected=true 也需 + // 内容非空;wsConnected=false 同理) + ChatSendMessage: [ + { + // WS 已连 + 内容非空 → 走 WS(sendingViaWs 等 AI 流式回 reply) + guard: ({ context, event }) => + context.wsConnected && event.content.trim().length > 0, + actions: "appendUserMessage", + target: "sendingViaWs", + }, + { + // WS 未连(或 fallback) + 内容非空 → 走 HTTP(sendingViaHttp 一次性回 reply) + guard: ({ event }) => event.content.trim().length > 0, + actions: "appendUserMessage", + target: "sendingViaHttp", + }, + ], ChatSendImage: { actions: "appendUserImage", }, @@ -398,13 +426,45 @@ export const chatMachine = setup({ }, ChatLogout: "#chat.idle", ChatGuestLogin: "#chat.guestSession", - ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" }, - ChatWebSocketError: { actions: "appendSocketErrorMessage" }, - ChatWebSocketConnected: { actions: "setWsConnected" }, ChatQuotaExceeded: { actions: "incrementQuotaExceeded" }, + // 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected + // 已上提到 userSession.on,子级不再声明 }, }, - sending: { + // WS 发送:invoke sendMessageWsActor 触发一次 send, + // 之后 AI 流式回 reply 走 userSession.on 的 ChatAISentenceReceived → appendOrUpdateAISentence + // 当句尾 done: true → isReplyingAI = false → always 跳回 ready + sendingViaWs: { + on: { + // 流式回包中 WS 出错(mid-stream) → 加错误泡 + 跳回 ready(不卡在 sendingViaWs) + // 这个 child handler 替换 userSession.on 的 ChatWebSocketError(XState v5 不合并) + // 所以需要重复声明 action: "appendSocketErrorMessage" + ChatWebSocketError: { + target: "ready", + actions: "appendSocketErrorMessage", + }, + }, + invoke: { + src: "sendMessageWs", + input: ({ event }) => ({ + content: event.type === "ChatSendMessage" ? event.content : "", + }), + onError: { + // sendMessageWsActor 自身抛错(无 active ws / ws not OPEN) → 回 ready + 清 typing + target: "ready", + actions: assign({ isReplyingAI: false }), + }, + }, + // 监听 isReplyingAI —— appendOrUpdateAISentence 在 done: true 时设 false + always: [ + { + target: "ready", + guard: ({ context }) => !context.isReplyingAI, + }, + ], + }, + // HTTP 发送:一次发送 = 一次返回(保留原 sending 状态语义,guest 与 ws-down fallback 走此) + sendingViaHttp: { invoke: { src: "sendMessageHttp", input: ({ event }) => ({ diff --git a/src/utils/facebook-graph.ts b/src/utils/facebook-graph.ts new file mode 100644 index 00000000..e8980a4f --- /dev/null +++ b/src/utils/facebook-graph.ts @@ -0,0 +1,108 @@ +/** + * Facebook Graph API 工具 + * + * 在 OAuth callback 之后、auth state machine 的 `syncingFacebookBackend` actor + * 成功换取业务 token 之后调用 —— 用 accessToken 调 `/me` 拉用户资料(id / name + * / email / picture),把 pictureUrl 写入 `UserStorage.setAvatarUrl()`、 + * id 写入 `AuthStorage.setFacebookId()`。 + * + * 为什么不依赖后端返回的 avatarUrl? + * - 后端可能未抓取 / 未存头像 + * - 头像有时效性(用户改 Facebook 头像),前端直接拉最新的 + * - Facebook ID 也独立存一份(与后端 userId 解耦,给 deep link 用) + * + * 不引入 Facebook SDK(window.FB)—— 减少前端 bundle 体积,Graph API + * 单次 HTTP 调用即可拿到所有字段。SDK 加载慢、还要管 init。 + * + * 端点:`https://graph.facebook.com/me?fields=id,name,email,picture.type=large&access_token=...` + * - `picture.type=large` 返回 ~200px 头像 URL(默认 square 50px 太小) + * - 不指定 version 走最新稳定版 + * - 不需要 appId / appSecret(accessToken 自身就是凭证) + */ +import { FacebookUserData } from "@/data/dto/auth/facebook_user_data"; +import { Logger } from "./logger"; +import { Result, toError } from "./result"; + +const log = new Logger("FacebookGraph"); + +/** Facebook Graph `/me` 端点。 */ +const GRAPH_ME_URL = "https://graph.facebook.com/me"; + +/** + * 拉取字段集合: + * - id / name / email —— 给本地 FacebookUserData DTO + * - picture.type=large —— 头像 URL(默认 square 50px 太小) + */ +const FIELDS = "id,name,email,picture.type=large"; + +/** + * 用 accessToken 调 Facebook Graph `/me` 拉用户资料。 + * + * 错误处理:返回 `Result` 而非 throw —— 调用方在 + * auth state machine actor 内可走「best-effort」路径,失败只 warn 不 + * 让 Facebook 登录整条流挂掉(后端业务 token 已经换到,头像只是锦上添花)。 + */ +export async function fetchFacebookUserData( + accessToken: string, +): Promise> { + if (!accessToken) { + return Result.err(new Error("fetchFacebookUserData: accessToken is empty")); + } + + const url = `${GRAPH_ME_URL}?fields=${encodeURIComponent(FIELDS)}&access_token=${encodeURIComponent(accessToken)}`; + + log.debug({ url: url.replace(accessToken, "") }, "fetchFacebookUserData REQUEST"); + + try { + const res = await fetch(url, { method: "GET" }); + const json = (await res.json()) as Record; + + if (!res.ok) { + const errBody = (json?.error as Record | undefined) ?? {}; + const message = + typeof errBody.message === "string" + ? errBody.message + : `Graph /me failed: HTTP ${res.status}`; + log.warn({ status: res.status, body: errBody }, "fetchFacebookUserData FAILED"); + return Result.err(new Error(message)); + } + + // 提取 picture.data.url —— Graph API 返回嵌套结构 + const pictureUrl = extractPictureUrl(json.picture); + + const data = FacebookUserData.from({ + id: typeof json.id === "string" ? json.id : null, + name: typeof json.name === "string" ? json.name : null, + email: typeof json.email === "string" ? json.email : null, + pictureUrl, + }); + + log.debug( + { + hasId: !!data.id, + hasName: !!data.name, + hasEmail: !!data.email, + hasPicture: !!data.pictureUrl, + }, + "fetchFacebookUserData SUCCESS", + ); + return Result.ok(data); + } catch (e) { + const err = toError(e); + log.error({ err }, "fetchFacebookUserData THREW"); + return Result.err(err); + } +} + +/** + * 安全提取 `picture.data.url` —— 容错处理: + * - 缺字段 / 类型不对 → null(头像可选) + * - Graph API 偶尔返回 `picture: false`(没设头像)→ null + */ +function extractPictureUrl(raw: unknown): string | null { + if (!raw || typeof raw !== "object") return null; + const data = (raw as { data?: unknown }).data; + if (!data || typeof data !== "object") return null; + const url = (data as { url?: unknown }).url; + return typeof url === "string" && url.length > 0 ? url : null; +}