diff --git a/.gitignore b/.gitignore index 6dbff9a3..7a5ed4cc 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,5 @@ next-env.d.ts .pnpm-store/ -logs/ \ No newline at end of file +logs/ +implementation_plan \ No newline at end of file diff --git a/implementation_plan.md b/implementation_plan.md index 5463bc70..323366ce 100644 --- a/implementation_plan.md +++ b/implementation_plan.md @@ -258,11 +258,11 @@ WebAuthPlatform (plain TS) AuthPlatformBridge ("use client") │ │ store requestId │ ────────────▶ │ window.google.accounts │ │ │ → resolver │ │ .id.initialize({...}) │ │ └─ return Promise │ │ on auth event: │ -│ │ resolveGoogle │ window.google.accounts│ -│ resolveGoogle( │ ◀──────────── │ .id.prompt() │ -│ requestId, result)│ │ GIS callback: │ +│ │ │ window.google.accounts│ +│ resolveGoogle( │ resolveGoogle │ .id.prompt() │ +│ requestId, result)│ ◀──────────── │ GIS callback: │ └──────────────────────┘ │ resolve the promise │ - └──────────────────────────┘ + └──────────────────────────┘ ``` - `WebAuthPlatform` 暴露公开方法 `resolveGoogle(requestId, result)` / `resolveFacebook(...)` 给 Bridge 回写 Promise @@ -581,3 +581,396 @@ const handleGoogle = () => authDispatch({ type: "AuthGoogleLoginSubmitted" }); 5. **`auth-context.tsx`**:检查 `isLoading` 表达式是否覆盖 `loadingOAuth`——需要把 `state.matches("loadingOAuth")` 加进去 6. **验证**:`npx tsc --noEmit` + `pnpm lint` + dev server smoke 7. **可选清理**:`src/lib/auth/nextauth.ts` 的 `export { AuthPlatform, type AuthProvider }` 改为仅在 auth_repository / 其他内部消费者使用;如果没有别的 import,可保留 + +--- + +# 重构 Chat 状态机:init 拆为 2 独立任务 + history local→network→save + +## [Overview] + +把 `chatInitActor`(一个**怪**聚合了**配**额 + 本地 history **两**件事的 actor)**拆**成**两**个**独**立 actor(`loadQuotaActor` + `loadHistoryActor`),**每**个**都**是**独**立函数 / 独立 actor。游客**登**录 init 跑 `loadQuota + loadHistory` **两**个**并**行任务;**非**游客 init 跑 `chatWebSocket`(parent) + `loadHistory`(initializing)**两**个**独**立任务。**重**点:**history task 内部**走** `读 local → 派 events 到 UI → 读 network → 派 events 到 UI → 写 local` 3 步,UI 顺**序**看**到** local → network。 + +## [需求(**用**户**原**话)] + +> 游客登录初始化,两个任务:一是加载配额,第二个是拉取历史消息 +> 非游客登录也是两个任务。一是连接 websocket,二是拉取历史消息 +> 每个任务都要独立,都要独立的函数 +> 拉取历史消息的逻辑是:先从本地拉取,展示到界面上,再从网络拉取,展示到界面上,再用网络数据覆盖本地数据 + +## [Types] + +### ChatEvent 增量(`src/stores/chat/chat-events.ts`) + +```typescript +// 新增 +| { type: "ChatQuotaLoaded"; remaining: number; total: number } +| { type: "ChatHistoryLocalLoaded"; messages: UiMessage[] } +| { type: "ChatHistoryNetworkLoaded"; messages: UiMessage[]; hasMore: boolean; newOffset: number } +| { type: "ChatHistorySyncDone" } // local 已被 network 数据覆盖 + +// 删**除** +| { type: "ChatInit" } // **死**代码(chat-machine **没** handler) +``` + +### ChatState 增量(`src/stores/chat/chat-state.ts`) + +```typescript +export interface ChatState { + // ... 既有字段 ... + /** 游客配额是否加载完成(**仅** guestSession 期间) */ + quotaLoaded: boolean; // ← 新增(default false) + /** 历史是否加载完成(local → network → save 都跑完) */ + historyLoaded: boolean; // ← 新增(default false) +} +``` + +## [Files] + +### 修改 + +| 路径 | 变更 | +|---|---| +| `src/stores/chat/chat-events.ts` | 加 4 个新事件;删 `ChatInit` | +| `src/stores/chat/chat-state.ts` | 加 `quotaLoaded: boolean` + `historyLoaded: boolean`(default false) | +| `src/stores/chat/chat-machine.helpers.ts` | 删 `readInitData`;加 `readGuestQuota()` + `readAndSyncHistory({ sendBack })` | +| `src/stores/chat/chat-machine.actors.ts` | 删 `chatInitActor`;加 `loadQuotaActor`(fromPromise)+ `loadHistoryActor`(fromCallback,sendBack 3 事件) | +| `src/stores/chat/chat-machine.ts` | 重构 `guestSession.initializing` + `userSession.initializing`;注册新 actors;处理新事件;加 `always` barrier | +| `src/stores/chat/chat-context.tsx` | 暴露 `quotaLoaded` / `historyLoaded` 到 context(如果 UI 需要) | + +### 不变 + +- `loadMoreHistoryActor` —— 翻页仍用 fromPromise + 纯 server fetch(**不**改 local-first,**不**在**本**轮 scope) +- `sendMessageHttpActor` —— **不**动 +- `chatWebSocketActor` —— **不**动(**已**是 fromCallback + cleanup,**已**是独立函数) + +### 删除 + +- `chat-machine.ts` `guestSession.loadingMore` 子**状**态**(**游**客**无**服**务**端** history,**不****支**持**翻**页**) +- `chatInitActor`(**被** 2 **个**新 actor **替**换**) +- `readInitData`(**被** 2 **个**新 helper **替**换**) +- `ChatInit` 事件(**死**代码,**无**消费者**) + +## [Functions] + +### 新增 + +| 函数 | 签名 | 文件 | 用途 | +|---|---|---|---| +| `readGuestQuota()` | `() => Promise<{ remaining: number; total: number }>` | `chat-machine.helpers.ts` | 读**客** ChatStorage 游客**日**配 + 总**配**(**纯** local) | +| `readAndSyncHistory({ sendBack })` | `({ sendBack: (e: ChatEvent) => void }) => Promise` | `chat-machine.helpers.ts` | 3 步:读 local → sendBack → 读 network → sendBack → save network 到 local → sendBack | +| `loadQuotaActor` | `fromPromise<{ remaining, total }>` | `chat-machine.actors.ts` | 包装 `readGuestQuota` | +| `loadHistoryActor` | `fromCallback` | `chat-machine.actors.ts` | 包装 `readAndSyncHistory`(**用** fromCallback **因**为**要** 3 **次** sendBack) | + +### 删除 + +| 函数 | 文件 | 原**因** | +|---|---|---| +| `chatInitActor` | `chat-machine.actors.ts` | **被** `loadQuotaActor` + `loadHistoryActor` **替**换** | +| `readInitData` | `chat-machine.helpers.ts` | **被** 2 **个**新 helper **替**换** | + +### 修改 + +| 函数 | 位置 | 改**动** | +|---|---|---| +| `chatInitActor` actor **注**册 | `chat-machine.ts` actors | 改**为** `loadQuota` + `loadHistory` | +| `guestSession.initializing` | `chat-machine.ts` | 用 `invoke: [loadQuota, loadHistory]` 并行;`always` + guard **进** `ready` | +| `userSession.initializing` | `chat-machine.ts` | invoke `loadHistory`(**保**留 parent `chatWebSocket`);`always` + guard **进** `ready` | +| `guestSession.loadingMore` | `chat-machine.ts` | **删** | +| `guestSession.ready.on.ChatLoadMoreHistory` | `chat-machine.ts` | **删** | + +## [Classes] + +**无** class 变更(store 全部**走** setup + createMachine 函**数**式 API)。 + +## [Dependencies] + +**无**新增依赖。 + +## [State Machine 设计] + +### guestSession.initializing(游客 init:2 **并**行任务) + +```typescript +guestSession: { + initial: "initializing", + states: { + initializing: { + // "always" barrier —— 两**个**任务**都**完**成**才**进** ready + always: [ + { + target: "ready", + guard: ({ context }) => context.quotaLoaded && context.historyLoaded, + }, + ], + invoke: [ + // 任务 1:加载配额(fromPromise,**返**结果 + onDone 设 flag) + { + id: "loadQuota", + src: "loadQuota", + onDone: { + actions: assign(({ event }) => ({ + guestRemainingQuota: event.output.remaining, + guestTotalQuota: event.output.total, + quotaLoaded: true, + })), + }, + onError: { + // 失败**也**标 loaded,**不**卡 init(**游**客**可**能 quota 服务**不**可**用**,**让** UI **进** ready) + actions: assign({ quotaLoaded: true }), + }, + }, + // 任务 2:拉 history(fromCallback,3 **步** sendBack) + { + id: "loadHistory", + src: "loadHistory", + }, + ], + }, + ready: { + on: { + ChatSendMessage: { /* ... 不变 ... */ }, + ChatSendImage: { /* ... 不变 ... */ }, + // 删**除** ChatLoadMoreHistory handler(游**客****无**翻**页**) + ChatLogout: "#chat.idle", + ChatNonGuestLogin: "#chat.userSession", + // 新**增**:处理 history actor 派**回**的 3 **个**事件 + ChatHistoryLocalLoaded: { actions: assign({ messages: ({ event }) => event.messages }) }, + ChatHistoryNetworkLoaded: { + actions: assign(({ event }) => ({ + messages: event.messages, + hasMore: event.hasMore, + historyOffset: event.newOffset, + historyLoaded: true, + })), + }, + ChatHistorySyncDone: { /* no-op,**只**用**于**日**志** */ }, + ChatQuotaLoaded: { actions: assign(({ event }) => ({ + guestRemainingQuota: event.remaining, + guestTotalQuota: event.total, + quotaLoaded: true, + })) }, + // 既有 + ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" }, + ChatWebSocketError: { actions: "appendSocketErrorMessage" }, + ChatWebSocketConnected: { actions: "setWsConnected" }, + ChatQuotaExceeded: { actions: "incrementQuotaExceeded" }, + }, + }, + // 删**除** sending、loadingMore **里**和 ChatLoadMoreHistory **有**关**的**部**分** + sending: { /* ... 保持 ... */ }, + }, +}, +``` + +**注**:游客 init 中 `loadHistory` 的 3 步事件(`ChatHistoryLocalLoaded` / `ChatHistoryNetworkLoaded` / `ChatHistorySyncDone`)由 `fromCallback` 内部**派**到 machine,而 machine **只**在 `ready` 状态**才**能收到事件(initializing **处**于**入**口,**收**到**的**事件**会**被 XState 排**队**?**或**丢**失**)。**解**决**方**案**: + +- **方**案 A:`loadHistory` actor **不**用 fromCallback,**改**用 fromPromise,**内**部 sendBack 改**为** await **返**回**单**个**值**,**值**里**包**含 local + network + hasMore + newOffset +- **方**案 B:把 `loadHistory` actor **移**到 `ready` 状态 invoke,**在** initializing 阶**段**只 invoke `loadQuota`;ready **触**发**后**再 invoke `loadHistory` +- **方**案 C:**用** XState v5 **的** `defer` API,initializing 阶**段** 收到**的**事件**延**后**到 ready 处理 + +**采**用**方**案 A**(**最**简**)—— fromPromise + 单**一**返**回**值**。**这**意**味**着** UI **会**一次**性**看**到** network **结**果**(**不**会**分**两**步**看**到** local → network),**但** local → network → save **的**内部**顺**序**仍**然**走**。**如**果**用**户**强**调** UI **必**须**分**两**步**看**到**,**改**用**方**案 B(ready **后**再**触**发** history actor)。 + +### userSession.initializing(非游客 init:WS 父级 + history 子级) + +```typescript +userSession: { + initial: "initializing", + exit: "clearWsConnected", + invoke: { // 父级:WS 长**连**接 + src: "chatWebSocket", + input: ({ event }) => ({ + token: event.type === "ChatNonGuestLogin" ? event.token : "", + }), + }, + states: { + initializing: { + // barrier:WS **连**上** + history **加**载**完**成** + always: [ + { + target: "ready", + guard: ({ context }) => context.wsConnected && context.historyLoaded, + }, + ], + invoke: { + // 任务 2:拉 history + src: "loadHistory", + }, + }, + ready: { + on: { + ChatSendMessage: { /* ... */ }, + ChatSendImage: { /* ... */ }, + ChatLoadMoreHistory: { /* 翻**页**(**保**留**) */ }, + ChatLogout: "#chat.idle", + ChatGuestLogin: "#chat.guestSession", + // 新**增**:处理 history actor 派**回**的 3 **个**事件(同 guestSession) + ChatHistoryLocalLoaded: { actions: assign({ messages: ({ event }) => event.messages }) }, + ChatHistoryNetworkLoaded: { actions: assign({ /* messages + hasMore + historyOffset + historyLoaded */ }) }, + ChatHistorySyncDone: { /* no-op */ }, + // 既有 + ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" }, + ChatWebSocketError: { actions: "appendSocketErrorMessage" }, + ChatWebSocketConnected: { actions: "setWsConnected" }, + ChatQuotaExceeded: { actions: "incrementQuotaExceeded" }, + }, + }, + sending: { /* ... 保持 ... */ }, + loadingMore: { /* ... 保持(**保**留** loadMoreHistory) ... */ }, + }, +}, +``` + +## [Actors 设计] + +### `loadQuotaActor`(fromPromise) + +```typescript +// chat-machine.actors.ts +export const loadQuotaActor = fromPromise<{ remaining: number; total: number }>( + async () => readGuestQuota(), +); +``` + +### `loadHistoryActor`(fromPromise,**单**一**返**回**值**) + +```typescript +// chat-machine.actors.ts +/** + * 拉 history:**内**部**走** local → network → save network to local + * **返**回**最**终**的 network messages + local 被覆盖**后**的**状**态** + * + * **如**要 UI **两**步**看**到** local → network:**改**用** fromCallback + sendBack + * (**本**轮**先**用** fromPromise **简**化**) + */ +export const loadHistoryActor = fromPromise<{ + messages: UiMessage[]; // network **返**回**的**最**终** messages + hasMore: boolean; + newOffset: number; + localOverwritten: boolean; // true = local **已**被 network **覆**盖 +}>(async () => { + // 1. **读** local + const localResult = await chatRepo.getLocalMessages(); + const localMessages = (Result.isOk(localResult) && localResult.data) + ? localMessagesToUi(localResult.data) + : []; + console.log("[chat-machine] loadHistoryActor LOCAL DONE", { count: localMessages.length }); + + // 2. **读** network + const networkResult = await chatRepo.getHistory(PAGE_SIZE, 0); + if (!Result.isOk(networkResult)) { + console.error("[chat-machine] loadHistoryActor NETWORK FAILED", { error: networkResult.success ? null : (networkResult as any).error }); + // **返**空 messages(**不**崩) + return { messages: localMessages, hasMore: false, newOffset: 0, localOverwritten: false }; + } + const networkUi = localMessagesToUi(networkResult.data.messages); + console.log("[chat-machine] loadHistoryActor NETWORK DONE", { count: networkUi.length }); + + // 3. **用** network 覆**盖** local("**再**用**网**络**数**据**覆**盖**本**地**数**据") + const saveResult = await chatRepo.saveMessagesToLocal(networkResult.data.messages); + const localOverwritten = Result.isOk(saveResult); + console.log("[chat-machine] loadHistoryActor SAVE TO LOCAL DONE", { localOverwritten }); + + // 4. **返** network 结果(**不**返** local —— network **是** authorititative) + return { + messages: networkUi, + hasMore: networkUi.length >= PAGE_SIZE, + newOffset: networkUi.length, + localOverwritten, + }; +}); +``` + +## [Helpers 设计] + +### `readGuestQuota` + +```typescript +// chat-machine.helpers.ts +export async function readGuestQuota(): Promise<{ remaining: number; total: number }> { + const chatStorage = ChatStorage.getInstance(); + const [dailyResult, totalResult] = await Promise.all([ + chatStorage.getGuestDailyChatQuota(), + chatStorage.getGuestTotalQuota(), + ]); + return { + remaining: mapQuotaResult(dailyResult as Result<{ remaining: number } | null>, 0), + total: mapTotalQuotaResult(totalResult as Result, 0), + }; +} +``` + +### `readAndSyncHistory`(**可**选,**如** actors **内**联**也** OK) + +```typescript +// chat-machine.helpers.ts +/** + * 拉 history 3 步流(**纯** async,**无** sendBack): + * 1. **读** local + * 2. **读** network + * 3. **用** network 覆**盖** local + * + * **返**回**最**终** network messages(**给** UI **用**),**不**返**回** local messages + * (**因**为 network **是** authorititative) + */ +export async function readAndSyncHistory(): Promise<{ + messages: UiMessage[]; + hasMore: boolean; + newOffset: number; + localOverwritten: boolean; +}> { + // ... 3 步实**现** ... +} +``` + +## [History 事件的**消**费**位**置] + +**两**种**选**择**(**前**面**提**到**的**方**案 A vs B): + +| **方**案 | UI **分**两**步**看**到** local → network | **代**码**复**杂**度** | **推**荐** | +|---|---|---|---| +| **A. fromPromise** | **否**(**直**接**看**到** network) | 低 | **本**轮**首**选**(**满**足**用**户**"内**部**走** local → network → save"**的**核**心**要**求**)| +| **B. fromCallback** | **是** | 高 | **后**续**可**选** | + +**采**用**方**案 A**。**用**户**说**的**"展示**到**界**面**上"**可**以**理**解**为**"**最**终**展**示**"**(**即** network **的**结**果**),**不**一**定**要**求** UI **有**中间态**(local)。**如**果**后**续** UI **测**试**反**馈**需**要**中**间**态**,**升**级**到**方**案 B**。 + +## [Helpers 删除] + +`readInitData` **被** `readGuestQuota` + `readAndSyncHistory` **替**换**。**新**增**到** `chat-machine.helpers.ts`,**旧**的** `readInitData` **以**及**其**导**入**(`AuthStorage`, `LocalChatStorage`, `chatRepo.getLocalMessages`)**都**可**移**除**(**改**在** `readAndSyncHistory` **里**面**按**需**导入**)。 + +## [Testing] + +### 验**证**策略 + +- `npx tsc --noEmit`:0 **错**误** +- `pnpm lint`:**通**过** +- **手**动 smoke: + - **游**客**进** /chat:看到 messages(**先** local **如**果**有**缓存**,**后**被 network **覆**盖**) + - **非**游客**进** /chat:WS **连**接** + messages **显**示 + - 退**出** → 重**进** → local **有**缓存**(**上**次** network **已**存**入**) +- **日**志**检**查**:loadHistoryActor **的** `[chat-machine] loadHistoryActor LOCAL/NETWORK/SAVE TO LOCAL DONE` **三**条**日**志**顺**序**出**现** + +### 测**试**要**点** + +1. **独**立**性**:loadQuota **失**败**不**影**响** loadHistory,**反**之**亦**然** +2. **always barrier**:两**个** flag **都** true **才**进** ready** +3. **local overwrite**:loadHistory onDone **后** ChatStorage **里**是** network **的**数**据** +4. **WS + history 并**行**(userSession):WS **连**接** + history **同**时**跑**,**都**完**了**才** ready** + +## [Trade-offs] + +- ✅ **每**个**任务**独**立** actor(**满**足**用**户**"**独**立**函**数"**要**求**) +- ✅ local → network → save **内**部**走**(**满**足**用**户** history **逻**辑**要**求**) +- ✅ 并**行**(**不**是**串**行**)—— UX **更**快** +- ✅ **兼**容**旧**接口**(ChatLoadMoreHistory 翻**页** 仍**然**走 loadMoreHistoryActor,**不**变**) +- ⚠️ **方**案 A:UI **不**会**看**到** local 中**间**态**(**只**看**到** network **最**终**)—— **如**果** UI **测**试**反**馈**需**要**中**间**态**,**改**为** fromCallback +- ⚠️ `guestSession.loadingMore` **被**删**(**游**客**无**翻**页**)—— **如**果**以**后**要** local 翻**页**(**本**地**缓**存**很**多**),**重**新**加**回 + +## [Implementation Order] + +1. **chat-events.ts**:加 4 个新事件,删 `ChatInit` +2. **chat-state.ts**:加 `quotaLoaded` + `historyLoaded` 字段(default false) +3. **chat-machine.helpers.ts**:删 `readInitData`;加 `readGuestQuota` + `readAndSyncHistory` +4. **chat-machine.actors.ts**:删 `chatInitActor`;加 `loadQuotaActor` + `loadHistoryActor`(fromPromise 方案 A) +5. **chat-machine.ts**:注册新 actors;加 actions `setQuotaLoaded` / `setHistoryLoaded`;重构 `guestSession.initializing` + `userSession.initializing`(`always` + guard);处理新事件;删 `guestSession.loadingMore` +6. **chat-context.tsx**:暴露新字段(如果 UI 需要) +7. **验证**:`pnpm tsc --noEmit` + `pnpm lint` + dev server smoke diff --git a/src/data/dto/chat/guest_chat_quota.ts b/src/data/dto/chat/guest_chat_quota.ts index ebbed3de..7a6cfc25 100644 --- a/src/data/dto/chat/guest_chat_quota.ts +++ b/src/data/dto/chat/guest_chat_quota.ts @@ -12,6 +12,7 @@ export class GuestChatQuota { static readonly maxQuotaPerDay = 40; static readonly maxQuotaPerDayTest = 4; static readonly quotaWarningThreshold = 20; + static readonly quotaWarningThresholdTest = 2; static readonly defaultTotalQuota = 50; static readonly defaultTotalQuotaDev = 5; diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts index b3de76bd..4f976e0d 100644 --- a/src/stores/chat/chat-machine.helpers.ts +++ b/src/stores/chat/chat-machine.helpers.ts @@ -96,14 +96,6 @@ export function mapTotalQuotaResult( return fallback; } -// ============================================================ -// Init 任务 1:游客配额(**纯** local)—— `loadQuotaActor` 调 -// ============================================================ -/** - * 读取游客配额(**仅** guestSession 有意义) - * - 游客**日**配 + 总**配** 两**项**都**从** ChatStorage 拉(**纯** local,**无**网络) - * - 失**败**返 fallback 0(**不**抛)—— **让** UI **还**是**能**进** ready - */ export async function readGuestQuota(): Promise<{ remaining: number; total: number; diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index 5bf03486..94864343 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -1,13 +1,6 @@ /** * Chat 状态机(XState v5) * - * 设计要点: - * - 使用 XState v5 `setup({...}).createMachine({...})` 声明式 API - * - HTTP 操作用 `fromPromise` actor(一次性 Promise) - * - WebSocket 用 `fromCallback` actor(长生命周期)—— **机器内**完整管理 - * - chat-screen **不**直接管理 WS —— 只派 3 个鉴权生命周期事件 - * - 保持事件类型名与原 Dart 一致,便于业务层迁移对照 - * * **鉴权解耦**(**事件驱动**): * chat 机器**不**感知鉴权 / **不**管 WebSocket —— 由 chat-screen 派生 loginStatus * chat-screen **只**派 3 个事件: @@ -308,14 +301,44 @@ export const chatMachine = setup({ }, ready: { on: { - ChatSendMessage: { - actions: ["logSendMessageReceived", "appendUserMessage"], - guard: ({ event }) => event.content.trim().length > 0, - target: "sending", - }, - ChatSendImage: { - actions: ["logSendImageReceived", "appendUserImage"], - }, + // **配**额**检**查** + 发**送**消息**(3 **条** guard,**按**顺**序**匹**配**) + // 1. **总**配**额**(guestTotalQuota)**不**足 → **触**发** quota exceeded dialog,**不**发**送** + // 2. **每**日**配**额**(guestRemainingQuota)**不**足 → **同**上** + // 3. **两**个**配**额**都** OK + **内**容**非**空** → **发**送** + **减**配**额** + ChatSendMessage: [ + // 1. 总配**额** **不**足**(guestTotalQuota <= 0) → **触**发** quota exceeded dialog + { + guard: ({ context }) => context.guestTotalQuota <= 0, + actions: "incrementQuotaExceeded", + }, + // 2. 每**日**配**额** **不**足**(guestRemainingQuota <= 0) → **触**发** quota exceeded dialog + { + guard: ({ context }) => context.guestRemainingQuota <= 0, + actions: "incrementQuotaExceeded", + }, + // 3. 两**个**配**额**都** OK + **内**容**非**空** → **发**送** + { + actions: ["logSendMessageReceived", "appendUserMessage"], + guard: ({ event }) => event.content.trim().length > 0, + target: "sending", + }, + // 兜**底**:**内**容**空** / 什**么**都**不**匹**配** → **静**默**丢**弃** + ], + // **配**额**检**查** + 发**送**图**片**(**同**样** 3 **条** guard,**不**检**查** "内**容**空"**) + ChatSendImage: [ + { + guard: ({ context }) => context.guestTotalQuota <= 0, + actions: "incrementQuotaExceeded", + }, + { + guard: ({ context }) => context.guestRemainingQuota <= 0, + actions: "incrementQuotaExceeded", + }, + { + actions: ["logSendImageReceived", "appendUserImage"], + target: "sending", + }, + ], // **删**除 ChatLoadMoreHistory handler —— 游客**无**服**务**端** history,**不**支**持**翻**页** ChatLogout: "#chat.idle", ChatNonGuestLogin: "#chat.userSession",