@@ -258,9 +258,9 @@ 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 │
└──────────────────────────┘
```
@@ -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<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(游客 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 < 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 **覆**盖 ** )
- **非**游客**进** /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