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:
+2
-1
@@ -48,4 +48,5 @@ next-env.d.ts
|
|||||||
|
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
|
|
||||||
logs/
|
logs/
|
||||||
|
implementation_plan
|
||||||
+397
-4
@@ -258,11 +258,11 @@ WebAuthPlatform (plain TS) AuthPlatformBridge ("use client")
|
|||||||
│ │ store requestId │ ────────────▶ │ window.google.accounts │
|
│ │ store requestId │ ────────────▶ │ window.google.accounts │
|
||||||
│ │ → resolver │ │ .id.initialize({...}) │
|
│ │ → resolver │ │ .id.initialize({...}) │
|
||||||
│ └─ return Promise │ │ on auth event: │
|
│ └─ return Promise │ │ on auth event: │
|
||||||
│ │ resolveGoogle │ window.google.accounts│
|
│ │ │ window.google.accounts│
|
||||||
│ resolveGoogle( │ ◀──────────── │ .id.prompt() │
|
│ resolveGoogle( │ resolveGoogle │ .id.prompt() │
|
||||||
│ requestId, result)│ │ GIS callback: │
|
│ requestId, result)│ ◀──────────── │ GIS callback: │
|
||||||
└──────────────────────┘ │ resolve the promise │
|
└──────────────────────┘ │ resolve the promise │
|
||||||
└──────────────────────────┘
|
└──────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- `WebAuthPlatform` 暴露公开方法 `resolveGoogle(requestId, result)` / `resolveFacebook(...)` 给 Bridge 回写 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")` 加进去
|
5. **`auth-context.tsx`**:检查 `isLoading` 表达式是否覆盖 `loadingOAuth`——需要把 `state.matches("loadingOAuth")` 加进去
|
||||||
6. **验证**:`npx tsc --noEmit` + `pnpm lint` + dev server smoke
|
6. **验证**:`npx tsc --noEmit` + `pnpm lint` + dev server smoke
|
||||||
7. **可选清理**:`src/lib/auth/nextauth.ts` 的 `export { AuthPlatform, type AuthProvider }` 改为仅在 auth_repository / 其他内部消费者使用;如果没有别的 import,可保留
|
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
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export class GuestChatQuota {
|
|||||||
static readonly maxQuotaPerDay = 40;
|
static readonly maxQuotaPerDay = 40;
|
||||||
static readonly maxQuotaPerDayTest = 4;
|
static readonly maxQuotaPerDayTest = 4;
|
||||||
static readonly quotaWarningThreshold = 20;
|
static readonly quotaWarningThreshold = 20;
|
||||||
|
|
||||||
static readonly quotaWarningThresholdTest = 2;
|
static readonly quotaWarningThresholdTest = 2;
|
||||||
static readonly defaultTotalQuota = 50;
|
static readonly defaultTotalQuota = 50;
|
||||||
static readonly defaultTotalQuotaDev = 5;
|
static readonly defaultTotalQuotaDev = 5;
|
||||||
|
|||||||
@@ -96,14 +96,6 @@ export function mapTotalQuotaResult(
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Init 任务 1:游客配额(**纯** local)—— `loadQuotaActor` 调
|
|
||||||
// ============================================================
|
|
||||||
/**
|
|
||||||
* 读取游客配额(**仅** guestSession 有意义)
|
|
||||||
* - 游客**日**配 + 总**配** 两**项**都**从** ChatStorage 拉(**纯** local,**无**网络)
|
|
||||||
* - 失**败**返 fallback 0(**不**抛)—— **让** UI **还**是**能**进** ready
|
|
||||||
*/
|
|
||||||
export async function readGuestQuota(): Promise<{
|
export async function readGuestQuota(): Promise<{
|
||||||
remaining: number;
|
remaining: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Chat 状态机(XState v5)
|
* 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 机器**不**感知鉴权 / **不**管 WebSocket —— 由 chat-screen 派生 loginStatus
|
||||||
* chat-screen **只**派 3 个事件:
|
* chat-screen **只**派 3 个事件:
|
||||||
@@ -308,14 +301,44 @@ export const chatMachine = setup({
|
|||||||
},
|
},
|
||||||
ready: {
|
ready: {
|
||||||
on: {
|
on: {
|
||||||
ChatSendMessage: {
|
// **配**额**检**查** + 发**送**消息**(3 **条** guard,**按**顺**序**匹**配**)
|
||||||
actions: ["logSendMessageReceived", "appendUserMessage"],
|
// 1. **总**配**额**(guestTotalQuota)**不**足 → **触**发** quota exceeded dialog,**不**发**送**
|
||||||
guard: ({ event }) => event.content.trim().length > 0,
|
// 2. **每**日**配**额**(guestRemainingQuota)**不**足 → **同**上**
|
||||||
target: "sending",
|
// 3. **两**个**配**额**都** OK + **内**容**非**空** → **发**送** + **减**配**额**
|
||||||
},
|
ChatSendMessage: [
|
||||||
ChatSendImage: {
|
// 1. 总配**额** **不**足**(guestTotalQuota <= 0) → **触**发** quota exceeded dialog
|
||||||
actions: ["logSendImageReceived", "appendUserImage"],
|
{
|
||||||
},
|
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,**不**支**持**翻**页**
|
// **删**除 ChatLoadMoreHistory handler —— 游客**无**服**务**端** history,**不**支**持**翻**页**
|
||||||
ChatLogout: "#chat.idle",
|
ChatLogout: "#chat.idle",
|
||||||
ChatNonGuestLogin: "#chat.userSession",
|
ChatNonGuestLogin: "#chat.userSession",
|
||||||
|
|||||||
Reference in New Issue
Block a user