docs(plan): add chat state machine refactor design

Update implementation_plan.md with a detailed refactor plan that splits
`chatInitActor` into two independent actors (`loadQuotaActor` +
`loadHistoryActor`) and redesigns the history loading flow to follow
local → network → save semantics so the UI sees local data first,
then network data.

Key changes outlined in the plan:
- Add new events: `ChatQuotaLoaded`, `ChatHistoryLocalLoaded`,
  `ChatHistoryNetworkLoaded`, `ChatHistorySyncDone`
- Remove dead-code `ChatInit` event
- Extend `ChatState` with `quotaLoaded` and `historyLoaded` flags
- Use an `always` barrier in `guestSession.initializing` and
  `userSession.initializing` so both tasks must complete before
  transitioning to `ready`
- Guest init runs `loadQuota` + `loadHistory` in parallel; non-guest
  init runs `chatWebSocket` + `loadHistory` as independent tasks

Also adds `implementation_plan` to .gitignore.
This commit is contained in:
2026-06-15 17:05:32 +08:00
parent 9b28404673
commit fa694af723
5 changed files with 438 additions and 28 deletions
+2 -1
View File
@@ -48,4 +48,5 @@ next-env.d.ts
.pnpm-store/
logs/
logs/
implementation_plan
+397 -4
View File
@@ -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`fromCallbacksendBack 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<void>` | `chat-machine.helpers.ts` | 3 步:读 local → sendBack → 读 network → sendBack → save network 到 local → sendBack |
| `loadQuotaActor` | `fromPromise<{ remaining, total }>` | `chat-machine.actors.ts` | 包装 `readGuestQuota` |
| `loadHistoryActor` | `fromCallback<ChatEvent, void>` | `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(游客 init2 **并**行任务)
```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:拉 historyfromCallback3 **步** 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` APIinitializing 阶**段** 收到**的**事件**延**后**到 ready 处理
**采**用**方**案 A****最**简**)—— fromPromise + 单**一**返**回**值**。**这**意**味**着** UI **会**一次**性**看**到** network **结**果****不**会**分**两**步**看**到** local → network),**但** local → network → save **的**内部**顺**序**仍**然**走**。**如**果**用**户**强**调** UI **必**须**分**两**步**看**到****改**用**方**案 Bready **后**再**触**发** history actor)。
### userSession.initializing(非游客 initWS 父级 + history 子级)
```typescript
userSession: {
initial: "initializing",
exit: "clearWsConnected",
invoke: { // 父级:WS 长**连**接
src: "chatWebSocket",
input: ({ event }) => ({
token: event.type === "ChatNonGuestLogin" ? event.token : "",
}),
},
states: {
initializing: {
// barrierWS **连**上** + 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<number | null>, 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 **覆**盖**
- **非**游客**进** /chatWS **连**接** + 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**不**变**
- ⚠️ **方**案 AUI **不**会**看**到** 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
+1
View File
@@ -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;
-8
View File
@@ -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;
+38 -15
View File
@@ -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",