From 9ebf4478811a2503de10c91fabad2d461a8155bd Mon Sep 17 00:00:00 2001 From: CoderLambert Date: Fri, 11 Sep 2026 21:02:29 +0800 Subject: [PATCH 1/3] docs: deepen system architecture notes from source --- ...01-system-architecture-source-deep-dive.md | 731 ++++++++++++++++++ 1 file changed, 731 insertions(+) create mode 100644 docs/notes/01-system-architecture-source-deep-dive.md diff --git a/docs/notes/01-system-architecture-source-deep-dive.md b/docs/notes/01-system-architecture-source-deep-dive.md new file mode 100644 index 0000000..cde3917 --- /dev/null +++ b/docs/notes/01-system-architecture-source-deep-dive.md @@ -0,0 +1,731 @@ +# Codex + TanStack Start:源码驱动的系统架构深挖 + +> 基线:`main@294ecae6` +> +> 本文不是对 `01-system-architecture.md` 的简单扩写,而是从真实源码出发,把架构概念还原成工程决策。统一学习模板:**源码位置 → 为什么需要 → 概念定义 → 当前实现 → 生产风险 → 工业级范式 → 复习检查**。 + +--- + +## 1. 从源码而不是目录树建立系统边界 + +关键调用链: + +```text +src/features/chat/components/chat-page.tsx + ↓ UI event +src/features/chat/use-chat-controller.ts + ↓ streamChat() +src/server-functions/chat.ts + ↓ server-only seam +src/server-functions/chat.runtime.server.ts + ↓ +src/server-functions/chat-stream.ts + ↓ CodexRuntime +src/server/codex/codex-app-server.server.ts + ↓ stdio JSON messages +codex app-server +``` + +响应反向经过: + +```text +Codex notification + ↓ +CodexThreadEvent + ↓ normalize +ChatEvent + ↓ adapt +ChatStateEvent + ↓ reduce +ChatState + ↓ render +React UI +``` + +这条链路说明本项目真正的架构单元不是“前端 / 后端”两个盒子,而是五种不同责任: + +1. **Presentation**:React 只展示应用状态。 +2. **Orchestration**:Controller 管理异步副作用与生命周期。 +3. **Application Contract**:`ChatEvent` 定义浏览器允许理解什么。 +4. **Runtime Adapter**:`CodexRuntime` 隔离供应商协议。 +5. **Infrastructure**:child process、stdio、本地认证、workspace。 + +### 为什么要这样分 + +因为这五层的变化频率不同: + +```text +UI 改版 高频 +React 状态结构 中频 +产品事件语义 中低频 +Codex 协议 外部变化 +进程 / OS / 认证 基础设施变化 +``` + +如果把它们直接揉在一起,任何一层变化都会向上扩散。 + +### 工业级判断 + +一个架构边界是否合理,不看文件夹名字,而看: + +```text +这一层是否只依赖比自己更稳定的抽象? +这一层是否只暴露下游真正需要的语义? +外部供应商变化是否能被限制在局部? +``` + +--- + +## 2. Browser / Server / Runtime:这是信任边界,不只是部署边界 + +### 源码 + +`src/server-functions/chat.ts`: + +```ts +export const streamChat = createServerFn({ method: 'POST' }) + .validator(validateChatRequest) + .handler(async function* ({ data }) { + try { + yield* streamNormalizedChatEvents(data, streamCodexTurn) + } catch (error) { + console.error('Codex chat stream failed', error) + yield { + type: 'error' as const, + message: 'Codex turn failed. Check the server logs for details.', + } + } + }) +``` + +这里最重要的不是 `createServerFn` 的语法,而是: + +```text +Browser 可以 import 函数形状 +≠ +Browser 可以执行 Runtime 实现 +``` + +真实的 Codex 逻辑继续放在 `*.server.ts` 和 `src/server/codex/**` 中。 + +### 为什么需要 server-only seam + +Runtime 依赖: + +- `node:child_process` +- `node:readline` +- 本机工作区路径 +- 本地 Codex 登录态 +- stdin/stdout/stderr +- 原始 Agent event + +这些都不属于浏览器信任域。 + +如果直接让 client graph 依赖 Runtime: + +```text +最轻:浏览器构建报 Node API 错误 +中等:bundle 泄漏内部实现和路径 +最重:原始协议、认证信息或工具结果越过安全边界 +``` + +### 工业级范式 + +```text +Client-importable RPC declaration + ↓ +Server-only integration seam + ↓ +Infrastructure adapter +``` + +不要仅依赖“团队约定不要 import”,而要通过文件边界、构建规则和测试使违规 import 尽早失败。 + +--- + +## 3. `CodexRuntime`:依赖倒置真正发生的位置 + +Runtime 抽象本质上是: + +```ts +interface CodexRuntime { + streamTurn(input: StreamCodexTurnInput): AsyncGenerator +} +``` + +### 定义 + +这是典型的 **Port / Adapter** 设计: + +```text +Application owns the port +Infrastructure implements the adapter +``` + +应用只要求: + +```text +给我一个 turn 的事件流 +``` + +而不要求: + +```text +必须 spawn codex +必须使用 JSON-RPC +必须经 stdio +必须叫 item/agentMessage/delta +``` + +### 为什么重要 + +未来 Runtime 可以变成: + +```text +CodexAppServerRuntime ─┐ +ClaudeRuntime ├─> CodexRuntime-like application port +PiRuntime ┤ +RemoteRuntime ┘ +``` + +真正可替换的前提不是“以后再重构”,而是现在 UI 和 Application 层没有偷偷依赖供应商 shape。 + +### 生产避坑 + +接口过薄也可能失去语义。如果未来需要: + +- interrupt +- resume +- history read +- capability negotiation +- approval +- multi-agent + +不要不断给 `streamTurn()` 塞可选参数。更好的方向是把 Runtime 能力显式建模: + +```text +ThreadPort +TurnPort +InterruptPort +HistoryPort +CapabilityPort +``` + +按真实演进拆分,而不是提前设计一个巨型接口。 + +--- + +## 4. `ChatEvent`:application-owned contract 为什么是架构核心 + +### 错误方案 + +如果 React 直接写: + +```ts +if (event.method === 'item/agentMessage/delta') { + // update UI +} +``` + +前端就已经绑定 Codex。 + +协议一旦改名、加层级、换 Runtime,UI 全部跟着变化。 + +### 当前设计 + +```text +Codex protocol + ↓ Runtime mapping +CodexThreadEvent + ↓ normalizer +ChatEvent + ↓ client adapter +ChatStateEvent + ↓ reducer +ChatState +``` + +这其实连续建立了两层 Anti-Corruption Layer。 + +### `ChatEvent` 的三个职责 + +**1. 稳定应用语义** + +```text +assistant.started +assistant.delta +assistant.completed +activity.started +turn.completed +error +``` + +这些是产品语义,不是供应商语义。 + +**2. 数据最小化** + +浏览器不需要看到: + +- reasoning 原文 +- command 全量 stdout/stderr +- MCP 原始 arguments/result +- 本地路径 +- 子进程错误细节 + +**3. 兼容多 Runtime** + +多个 Provider 只需要各自归一化成同一 `ChatEvent`。 + +### 工业级范式 + +Application contract 应遵守: + +```text +最小 +稳定 +可版本化 +可测试 +默认不暴露敏感字段 +不携带供应商特有对象 +``` + +不要把“第三方 event type 原样拷贝一份 TypeScript union”误认为 application contract。 + +--- + +## 5. Thread / Turn / Item:领域模型必须与 UI Message 分离 + +正确关系: + +```text +Thread +├── Turn +│ ├── reasoning item +│ ├── command item +│ ├── tool item +│ └── agentMessage item +└── Turn +``` + +而 React 常见的数据结构是: + +```text +messages[] +``` + +二者不是一回事。 + +### 为什么不能把 Thread 当 `Message[]` + +因为一个 Turn 以后可能包含: + +- 0 个或多个 assistant message +- 多个 tool activity +- interrupted 状态 +- usage +- approval +- files changed +- runtime error + +所以: + +```text +Message = UI projection +Turn = Agent work lifecycle +Thread = Long-lived Agent context +``` + +### 生产价值 + +提前分清以后,做下面功能时不会推翻模型: + +```text +Stop / Interrupt +Retry turn +Branch conversation +Tool timeline +Replay +Multi-agent handoff +Thread history +``` + +--- + +## 6. Controller / Reducer / UI:副作用与确定性状态的分层 + +当前: + +```text +useChatController + - RPC + - Date.now() + - localStorage + - async iterator + - generation guard + +chatReducer + - state + action -> next state + +ChatPage + - render + DOM effect +``` + +这是比“组件拆小”更重要的分层。 + +### `useReducer` 为什么适合这里 + +流式 Agent UI 是一个事件驱动状态机: + +```text +idle + ↓ turn.started +running + ├─ assistant.started + ├─ assistant.delta × N + ├─ activity.* + └─ turn.completed / error +``` + +如果全部用分散 `setState`: + +```text +setMessages +setStatus +setError +setActivities +setThreadId +``` + +跨状态不变量很难集中维护。 + +Reducer 则把状态变化变成显式 event transition。 + +### 当前值得修正的源码反例 + +`chat.reducer.ts` 的 fallback 分支仍直接执行: + +```ts +createdAt: Date.now() +``` + +这意味着 reducer 不再完全确定: + +```text +same state + same action +可能得到不同 result +``` + +更好的设计是让 adapter/controller 在 reducer 外注入时间。 + +### 原则 + +> Reducer 只转换事实,不创造事实。 + +时间、随机 ID、网络、storage、日志都属于 reducer 外部。 + +--- + +## 7. `useCallback`、`useRef`、闭包:不要把性能 API 与正确性混为一谈 + +`useChatController()` 当前: + +```ts +const stateRef = useRef(state) + +useEffect(() => { + stateRef.current = state +}, [state]) + +const sendMessage = useCallback(async (content: string) => { + if (stateRef.current.status === 'running') return + // ... +}, []) +``` + +### `useCallback` 的定义 + +它缓存的是: + +```text +function identity +``` + +不是函数执行结果。 + +它真正有价值的场景通常是: + +- callback 传给 memoized child +- callback 作为其它 Hook dependency +- 外部系统要求稳定 handler identity + +不是“所有函数都包一下性能更好”。 + +### 为什么这里需要理解 stale closure + +空依赖 callback 会捕获首次 render 的值。 + +所以代码没有直接读: + +```ts +state.status +``` + +而是读: + +```ts +stateRef.current.status +``` + +这是典型 latest-value ref 模式。 + +### 风险 + +当越来越多状态被塞进 `stateRef.current`,代码会绕开 React dependency model。 + +工业级判断: + +```text +先问 callback 为什么必须稳定 +如果没有明确消费者要求稳定 +优先使用普通函数/正确依赖 +不要为了 [] 依赖而引入一堆 ref +``` + +--- + +## 8. `useMemo`:为什么当前项目没有强行使用反而是正确的 + +### 定义 + +```ts +const value = useMemo(() => compute(input), [input]) +``` + +它缓存的是**计算结果**。 + +主要解决: + +1. 昂贵纯计算重复执行; +2. 下游依赖 value reference identity。 + +### 它不是什么 + +`useMemo` 不是: + +- 业务正确性机制 +- 永久缓存 +- 所有派生值的默认写法 +- 自动让代码更快的开关 + +例如: + +```ts +const isRunning = status === 'running' +``` + +没有理由写成: + +```ts +const isRunning = useMemo(() => status === 'running', [status]) +``` + +memo 本身也有 dependency tracking 和 cache 管理成本。 + +### 工业级范式 + +```text +先写直接纯计算 + ↓ +Profiler / measurement + ↓ +确认热点或引用稳定问题 + ↓ +局部 useMemo + ↓ +再次测量 +``` + +对于本项目,更值得先关注的是高频 token delta 触发的整体 render 次数,而不是对廉价派生值做微优化。 + +--- + +## 9. 持久化边界:UI transcript 不是 Agent truth + +`chat.storage.ts` 只持久化: + +```ts +{ + threadId, + messages, +} +``` + +没有持久化: + +```text +status +activities +error +runtime process +raw events +``` + +这是正确的最小持久化边界。 + +### 为什么 + +刷新后真正需要恢复的是: + +```text +用户可见 transcript ++ +继续连接 Runtime thread 的 threadId +``` + +而 `running/error/activity` 属于瞬时状态。 + +### 版本 envelope + +源码使用: + +```ts +{ + version: CHAT_STORAGE_VERSION, + conversation +} +``` + +这是一个很值得保留的生产范式。任何长期客户端持久化都应默认考虑 schema evolution,而不是直接 `JSON.stringify(state)`。 + +--- + +## 10. V0 read-only:安全必须是组合属性 + +Runtime 固定: + +```text +approvalPolicy: never +sandbox: read-only +networkAccess: false +``` + +Workspace 还通过: + +```text +realpath +relative +allowedRoot +``` + +把浏览器提交的路径限制在允许根目录内。 + +Server Function 最后又做错误脱敏: + +```text +server log = 详细错误 +browser event = 通用错误信息 +``` + +因此安全不是一个 `readOnly=true`: + +```text +Capability restriction ++ Path confinement ++ No interactive escalation ++ Data minimization ++ Error sanitization +``` + +### 工业级原则 + +> Agent execution security 与 data exposure security 是两条独立边界。 + +Sandbox 防止 Agent 做危险事;Application Contract 防止敏感数据进入浏览器。缺一不可。 + +--- + +## 11. 调试路径:永远沿边界定位,不要从 UI 猜 Runtime + +推荐顺序: + +```text +1. UI 是否触发 sendMessage +2. Controller 是否 dispatch turn.started +3. ServerFn validator 是否通过 +4. streamTurn 是否启动 +5. app-server initialize 是否成功 +6. thread/start 或 resume 是否成功 +7. turn/start 是否成功 +8. 是否收到 item/agentMessage/delta +9. normalizer 是否产出 ChatEvent +10. adapter 是否产出 ChatStateEvent +11. reducer 是否正确 fold +12. UI 是否正确 render +``` + +对每一层只问两个问题: + +```text +输入是什么? +输出是什么? +``` + +这比“多打几个 console.log”更容易形成可重复的排障方法。 + +--- + +## 12. 架构演进方向 + +当前 V0 可以自然演进为: + +```text +V0 process-per-turn + read-only + ↓ +显式 Abort / interrupt + ↓ +长连接 Runtime supervisor + ↓ +结构化 Tool/Command timeline + ↓ +server-side durable conversation store + ↓ +multi-runtime adapter + ↓ +capability negotiation / approval policy + ↓ +multi-agent orchestration +``` + +演进时应守住三条不变量: + +1. Browser 不理解供应商原始协议。 +2. Application event contract 由应用自己拥有。 +3. Runtime 权限永远由服务端决定。 + +--- + +## 13. 复习检查表 + +- [ ] 能画出 Browser → ServerFn → Runtime → app-server → Event → Reducer 的完整链路。 +- [ ] 能解释 `*.server.ts` 为什么是信任边界。 +- [ ] 能区分 Thread、Turn、Item、UI Message。 +- [ ] 能解释 `CodexRuntime` 为什么是 Port,而 `CodexAppServerRuntime` 是 Adapter。 +- [ ] 能解释 `ChatEvent` 为什么不能等于 Codex 原始 event。 +- [ ] 能区分 Transport Contract 与 State Contract。 +- [ ] 能说明 reducer 为什么不应调用 `Date.now()`。 +- [ ] 能解释 `useCallback` 缓存 function identity,而 `useMemo` 缓存 value。 +- [ ] 能说明 latest-value ref 的价值与代价。 +- [ ] 能解释 browser transcript 为什么不是 Agent thread truth。 +- [ ] 能说明 read-only sandbox 与数据脱敏为什么是两条安全线。 +- [ ] 能沿完整链路定位“UI 不流式”的故障层。 + +--- + +## 14. 思考题 + +1. 如果把 `ChatEvent` 删除,让 React 直接消费 `CodexThreadEvent`,第一年和第三年的维护成本分别会发生什么? +2. 如果未来一个 Turn 同时有两个 assistant item,当前 `messages[]` 模型是否仍然成立? +3. `generationRef` 能阻止旧流污染 UI,但为什么它不等价于真正的 Runtime cancel? +4. 如果 Runtime 从 process-per-turn 改成长连接 supervisor,哪些边界应保持完全不变? +5. 如果要开放文件写入,除了把 sandbox 改成 writable,还需要新增哪些 capability、approval、audit 和 UI 边界? +6. 什么证据出现时,你才会在本项目里引入 `useMemo`? -- 2.54.0 From 675da1af964100d25b334d234cd0a15755b4493f Mon Sep 17 00:00:00 2001 From: CoderLambert Date: Fri, 11 Sep 2026 21:03:37 +0800 Subject: [PATCH 2/3] docs: deepen codex runtime streaming notes from source --- ...x-app-server-streaming-source-deep-dive.md | 911 ++++++++++++++++++ 1 file changed, 911 insertions(+) create mode 100644 docs/notes/02-codex-app-server-streaming-source-deep-dive.md diff --git a/docs/notes/02-codex-app-server-streaming-source-deep-dive.md b/docs/notes/02-codex-app-server-streaming-source-deep-dive.md new file mode 100644 index 0000000..dca6959 --- /dev/null +++ b/docs/notes/02-codex-app-server-streaming-source-deep-dive.md @@ -0,0 +1,911 @@ +# Codex App Server Streaming:源码驱动的 Runtime 深挖 + +> 基线:`main@294ecae6` +> +> 本文围绕 `src/server/codex/codex-app-server.server.ts` 的真实实现,重点理解:child process、stdio 协议、request/response correlation、notification、AsyncGenerator、AbortSignal、资源释放和供应商协议防腐层。统一模板:**源码位置 → 为什么需要 → 定义 → 当前实现 → 生产风险 → 工业级范式 → 复习检查**。 + +--- + +## 1. Runtime 的真正职责:把“进程协议”变成“应用事件流” + +当前 Runtime 做的事情可以压缩为: + +```text +spawn codex app-server + ↓ +initialize handshake + ↓ +thread/start | thread/resume + ↓ +turn/start + ↓ +consume stdout messages + ↓ +map provider event + ↓ +yield CodexThreadEvent + ↓ +cleanup child process +``` + +因此 Runtime 不是“调用模型 API 的函数”,而是一个**协议客户端 + 生命周期管理器 + 数据翻译器**。 + +对应源码: + +```text +src/server/codex/codex-app-server.server.ts +src/server/codex/codex-runtime.ts +src/server/codex/codex.errors.ts +src/server/codex/thread-selection.server.ts +src/server/codex/workspace.server.ts +``` + +--- + +## 2. `spawn()`:为什么 Agent Runtime 更像进程监督而不是普通 HTTP 请求 + +源码: + +```ts +this.child = spawn(codexPath, ['app-server', '--stdio'], { + cwd: process.cwd(), + stdio: 'pipe', +}) +``` + +### 定义 + +Node `child_process.spawn()` 启动一个独立 OS 进程,并暴露: + +```text +stdin -> host 写给 child +stdout -> child 写给 host +stderr -> 诊断输出 +exit -> 生命周期信号 +``` + +与 `exec()` 相比,`spawn()` 更适合长生命周期和流式协议,因为它不会先把整个输出缓存在内存里再返回。 + +### 为什么当前场景需要它 + +Codex App Server 是持续交互协议: + +```text +host request +server response +server notification +host request +server notification +... +``` + +如果使用一次性命令执行模型: + +```text +command -> wait -> whole output +``` + +就无法自然表达一个 turn 中持续出现的 delta、tool event 和 usage。 + +### 生产风险 + +child process 不是普通 Promise。必须处理: + +- executable 不存在; +- 启动权限失败; +- stdout 提前关闭; +- stderr 持续增长; +- child 卡死; +- host 请求取消; +- parent 退出后 orphan process; +- kill 后进程没有及时退出。 + +### 工业级范式 + +把进程抽象成明确状态机: + +```text +CREATED + ↓ spawn +STARTING + ↓ initialize +READY + ↓ turn/start +RUNNING + ↓ complete / fail / abort +CLOSING + ↓ exit +CLOSED +``` + +生产实现最好对每个状态定义: + +```text +允许的操作 +超时 +退出条件 +日志字段 +失败错误码 +``` + +--- + +## 3. `readline` + AsyncIterator:为什么 stdout 是“消息流”而不是字符串 + +源码: + +```ts +this.lines = createInterface({ input: this.child.stdout }) +this.messages = this.lines[Symbol.asyncIterator]() +``` + +App Server 通过 newline-delimited JSON 风格的数据交换消息,因此 stdout 的正确抽象不是: + +```text +一个大字符串 +``` + +而是: + +```text +message 1 +message 2 +message 3 +... +``` + +### `Symbol.asyncIterator` 的意义 + +可以写出: + +```ts +const next = await this.messages.next() +``` + +或者更高层: + +```ts +for await (const line of lines) { + // consume one message at a time +} +``` + +这使协议解析与 Node stream 的 chunk 边界解耦。TCP/pipe chunk 并不保证一次 data event 就对应一个完整 JSON message;按“行”解析才符合协议 framing。 + +### 生产避坑 + +永远不要写: + +```ts +child.stdout.on('data', chunk => JSON.parse(chunk)) +``` + +原因: + +```text +一个 chunk 可能只有半条 JSON +一个 chunk 也可能包含多条 JSON +``` + +消息协议必须先处理 framing,再处理 parsing。 + +--- + +## 4. JSON-RPC correlation:为什么“发请求后读下一条消息”是错误模型 + +当前 `request()`: + +```ts +const id = this.send(method, params) +const notifications: AppServerMessage[] = [] + +while (true) { + const message = await this.nextMessage() + + if (message.id === id) { + return { response: message, notifications } + } + + if (this.isServerRequest(message)) { + this.rejectServerRequest(message) + } else if (message.method) { + notifications.push(message) + } +} +``` + +### 核心定义 + +RPC correlation 的本质: + +```text +request id -> matching response +``` + +而不是: + +```text +request -> next line is response +``` + +因为 response 到达前可能插入: + +- notification; +- server request; +- 其它并发 request 的 response。 + +### 当前实现的能力边界 + +当前连接实现更适合: + +```text +同一时刻一个 request() 顺序等待 +``` + +而不是完整并发 dispatcher。 + +如果未来一个连接同时发: + +```text +turn/start +thread/read +turn/interrupt +``` + +多个 `request()` 不能各自独立消费同一个 async iterator,否则会争抢消息。 + +### 工业级并发范式 + +应该升级成单一 reader loop: + +```text +stdout + ↓ +one dispatcher + ├─ response id -> pendingRequests Map + ├─ notification -> event channel + └─ server request -> request handler +``` + +伪代码: + +```ts +const pending = new Map() + +for await (const message of source) { + if (isResponse(message)) { + pending.get(message.id)?.resolve(message) + } else if (isNotification(message)) { + publish(message) + } else if (isServerRequest(message)) { + handleServerRequest(message) + } +} +``` + +这才支持真正的 multiplexing。 + +--- + +## 5. Request / Response / Notification / Server Request:四类消息必须严格区分 + +协议心智模型: + +| 类型 | id | method | 谁发起 | 宿主动作 | +|---|---|---|---|---| +| Request | 有 | 有 | Host | 等 response | +| Response | 有 | 无 | Server | resolve pending request | +| Notification | 无 | 有 | Server | 转成事件 | +| Server Request | 有 | 有 | Server | 必须回答 | + +当前代码识别 Server Request: + +```ts +return ( + message.id !== undefined && + message.method !== undefined && + message.result === undefined && + message.error === undefined +) +``` + +V0 统一拒绝: + +```ts +error: { + code: -32000, + message: 'Interactive server requests are disabled.', +} +``` + +### 为什么这是安全决策 + +因为 `approvalPolicy: never` 的 V0 不应该让 Runtime 在执行中临时向 Browser 请求权限升级。 + +### 未来怎么演进 + +不要直接改成“弹窗点允许”。应该引入: + +```text +Server Request + ↓ policy engine +capability + workspace + user policy + ↓ +approve / deny / require explicit user confirmation + ↓ +audit log +``` + +审批是安全协议,不是 UI 交互细节。 + +--- + +## 6. initialize / initialized:握手其实是连接状态机 + +源码: + +```ts +await connection.request('initialize', { + clientInfo: {...}, + capabilities: { + experimentalApi: true, + requestAttestation: false, + }, +}) +connection.notify('initialized') +``` + +### 为什么不能跳过 + +握手用来确认: + +- 客户端身份; +- 协议能力; +- 实验 API 支持; +- attestation 等交互能力。 + +这是一种 capability negotiation。 + +### 工业级范式 + +不要在业务逻辑里到处硬编码 capability JSON。长期应抽成: + +```ts +interface RuntimeCapabilities { + experimentalApi: boolean + requestAttestation: boolean + approvals: 'deny' | 'interactive' +} +``` + +并让初始化结果形成 ConnectionContext。 + +--- + +## 7. Thread resume:为什么浏览器给的 threadId 仍然不能直接信任 + +Runtime: + +```ts +const threadId = normalizeThreadId(input.threadId) +const threadRequest = threadId ? 'thread/resume' : 'thread/start' +``` + +### 原则 + +来自 Browser 的任何标识符都属于外部输入: + +```text +类型正确 ≠ 语义可信 +``` + +即使 TypeScript 类型是 `string`,运行时仍可能收到: + +- 空字符串; +- 非法格式; +- 超长值; +- stale id; +- 恶意构造值。 + +### 工业级范式 + +边界数据总是: + +```text +unknown + ↓ validate / normalize +trusted internal type +``` + +不要因为前后端共用 TypeScript 类型就省略运行时验证。 + +--- + +## 8. `mapAppServerItem()`:供应商对象为什么必须在 Runtime 内收敛 + +源码把 App Server item: + +```text +agentMessage +reasoning +commandExecution +fileChange +mcpToolCall +webSearch +error +``` + +映射成内部 `CodexThreadItem`。 + +特别值得注意的是 MCP: + +```ts +arguments: undefined, +result: undefined, +error: undefined, +``` + +这不是“数据没拿到”,而是数据最小化策略。 + +### 为什么 + +MCP arguments/result 可能包含: + +- 文件正文; +- token; +- 用户数据; +- 系统路径; +- 第三方服务响应。 + +Runtime 层是最适合做第一轮脱敏的地方。 + +### 工业级范式 + +Mapping function 应被视为 security boundary: + +```text +Provider Object + ↓ allow-list mapper +Internal Runtime Event +``` + +优先白名单,不使用: + +```ts +return { ...providerObject } +``` + +因为外部协议新增字段时,spread 会让新字段自动穿透安全边界。 + +--- + +## 9. Delta + Completed Snapshot:体验与最终事实必须分开 + +事件: + +```text +item/agentMessage/delta × N +item/completed(agentMessage) +``` + +正确理解: + +```text +delta = incremental transport +completed = authoritative snapshot +``` + +### 为什么 completed 不能省 + +实时 delta 可能因为: + +- transport 丢失; +- reconnect; +- adapter bug; +- provider 修订; +- chunk 重复; + +导致客户端拼出来的文本不完全可靠。 + +最终 snapshot 可以做 reconciliation: + +```text +实时:append delta +结束:replace with final snapshot +``` + +这和数据库中的: + +```text +optimistic projection + authoritative commit +``` + +非常相似。 + +--- + +## 10. Usage 是流式状态,不应假设只在结束时出现 + +源码: + +```ts +case 'thread/tokenUsage/updated': { + const last = ... + if (last) usage.value = last + return [] +} +``` + +最后: + +```ts +return [{ type: 'turn.completed', usage: usage.value }] +``` + +### 设计含义 + +Runtime 内维护一个最新 usage snapshot: + +```text +usage notification × N + ↓ +latest usage state + ↓ +turn.completed +``` + +这说明协议中的“统计信息”也可能是流,而不是单个最终 response 字段。 + +### 生产演进 + +如果需要实时成本 UI,可以将 usage 变成显式 application event;如果产品暂时不需要,就保持 Runtime 内部收敛,避免无意义事件放大。 + +--- + +## 11. `AbortSignal`:取消必须从 UI 一直传播到资源层 + +源码: + +```ts +if (input.signal?.aborted) { + throw new Error('Codex turn was cancelled.') +} + +const abortHandler = () => connection.close().catch(() => undefined) +input.signal?.addEventListener('abort', abortHandler, { once: true }) +``` + +### 定义 + +AbortSignal 是 cooperative cancellation: + +```text +caller 发出取消意图 +callee 监听 signal +callee 主动停止工作并释放资源 +``` + +它不是线程强杀机制。 + +### 为什么 generation guard 不够 + +前端 `generationRef` 只能做到: + +```text +旧结果不再写入 UI +``` + +但后台 Codex 进程仍可能继续: + +```text +占 CPU +占 token +占文件句柄 +占 child process +``` + +真正取消必须让 signal 穿过: + +```text +UI Stop + ↓ Controller +AbortController + ↓ Server/RPC +Runtime + ↓ +turn/interrupt 或 close child +``` + +### 工业级范式 + +区分: + +```text +stale-result suppression +vs +underlying-work cancellation +``` + +两个都要有。 + +--- + +## 12. `try / finally`:资源释放比成功路径更重要 + +源码: + +```ts +try { + // initialize + turn +} catch (error) { + throw normalizeCodexRuntimeError(error) +} finally { + input.signal?.removeEventListener('abort', abortHandler) + await connection.close() +} +``` + +### 为什么 `finally` 是 Runtime 核心 + +以下路径都必须释放: + +```text +正常完成 +RPC error +JSON parse error +用户 abort +app-server crash +mapper throw +consumer 提前停止 generator +``` + +只在“成功结束”时 close 是典型资源泄漏。 + +### 工业级检查表 + +每一种资源都问: + +```text +谁创建? +谁拥有? +谁关闭? +异常路径是否关闭? +取消路径是否关闭? +关闭本身失败怎么办? +``` + +--- + +## 13. `close()` 的生产风险:kill 不等于可靠退出 + +当前: + +```ts +this.child.once('exit', finish) +this.child.kill() +``` + +这是 V0 合理实现,但生产需要考虑: + +```text +SIGTERM 后 child 不退出怎么办? +close 等待是否可能永久 pending? +是否需要 grace period? +是否最终 SIGKILL? +Windows signal 行为是否一致? +``` + +推荐状态: + +```text +request graceful stop + ↓ timeout +SIGTERM + ↓ timeout +SIGKILL / platform equivalent + ↓ +record forced termination metric +``` + +不要让 cleanup 本身成为无限等待点。 + +--- + +## 14. Error Normalization:不要把底层错误字符串当领域模型 + +`codex.errors.ts` 定义: + +```text +INVALID_INPUT +INVALID_WORKSPACE +AUTH_REQUIRED +RUNTIME_START_FAILED +CODEX_RUNTIME_FAILED +``` + +### 为什么要分类 + +上层真正关心的是: + +```text +用户能不能修复? +应该重试吗? +应该引导登录吗? +是配置问题还是 Runtime crash? +``` + +而不是底层字符串: + +```text +spawn ENOENT +401 unauthorized +some provider-specific message +``` + +### 当前风险 + +当前部分分类依赖字符串匹配: + +```ts +normalized.includes('authentication') +normalized.includes('401') +``` + +这在 V0 可用,但长期脆弱。 + +### 工业级范式 + +优先级: + +```text +结构化 provider error code + > exit code / typed error + > protocol status + > 最后才是字符串 heuristic +``` + +并保持: + +```text +internal detailed error +!= +public browser-safe error +``` + +--- + +## 15. Backpressure:AsyncGenerator 不是“无限快地推” + +`streamTurn()` 是: + +```ts +async *streamTurn(...) { + yield event +} +``` + +消费者通过: + +```ts +for await (const event of source) { + // consume +} +``` + +### 心智模型 + +```text +producer yield + ↓ +consumer next() + ↓ +producer resumes +``` + +这提供天然的 cooperative backpressure。 + +但要注意:底层 app-server 仍持续写 stdout。如果 UI 或 RPC 消费明显慢于 provider,仍需要考虑: + +- pipe buffer; +- 内存队列; +- event batching; +- slow consumer metrics。 + +AsyncGenerator 能改善模型,但不会自动解决所有流控问题。 + +--- + +## 16. 当前实现最值得继续演进的三个点 + +### 16.1 单 reader dispatcher + +从顺序 `request()` 消费升级到: + +```text +one reader ++ pending request map ++ notification channel ++ server-request router +``` + +### 16.2 显式 timeout + +至少对: + +```text +spawn/init timeout +request timeout +turn idle timeout +close timeout +``` + +分别定义策略。 + +### 16.3 Runtime Supervisor + +process-per-turn 简单、安全、易回收,但启动成本高。 + +未来长连接模式可引入: + +```text +RuntimeSupervisor + ├─ connection health + ├─ pending requests + ├─ thread sessions + ├─ restart policy + └─ graceful shutdown +``` + +但只有在性能数据证明 process-per-turn 成为瓶颈后再引入。 + +--- + +## 17. 调试路径 + +遇到“没有流式输出”时按顺序检查: + +```text +1. child 是否成功 spawn +2. stderr 是否已有错误 +3. initialize response 是否成功 +4. thread/start|resume 是否返回 thread id +5. turn/start response 是否成功 +6. turnResult.notifications 是否已有 delta +7. nextNotification 是否继续收到消息 +8. 是否真的出现 item/agentMessage/delta +9. map/normalize 是否丢掉事件 +10. finally 是否过早 close +``` + +不要一开始就看 React。 + +如果 Runtime 根本没有 delta,前端不可能“优化”出真流式。 + +--- + +## 18. 复习检查表 + +- [ ] 能解释为什么用 `spawn()` 而不是一次性 `exec()`。 +- [ ] 能解释为什么 stdout chunk 不能直接当 JSON message。 +- [ ] 能区分 Request / Response / Notification / Server Request。 +- [ ] 能解释 request id correlation 的必要性。 +- [ ] 能指出当前连接为什么还不是完整并发 dispatcher。 +- [ ] 能解释 initialize / initialized 是状态机而不是礼貌握手。 +- [ ] 能说明 mapper 为什么属于安全边界。 +- [ ] 能解释 delta 与 completed snapshot 的职责差异。 +- [ ] 能区分 generation guard 与真正 Abort。 +- [ ] 能解释 `finally` 为什么是 Runtime 正确性的核心。 +- [ ] 能说出 child process close 需要哪些 timeout/fallback。 +- [ ] 能解释 typed error 比字符串错误更适合跨层传播。 +- [ ] 能解释 AsyncGenerator 提供什么 backpressure,又不提供什么。 + +--- + +## 19. 思考题 + +1. 如果同一个 App Server connection 上同时运行两个 turn,当前 `request()` 会有什么并发风险? +2. 如果 `item/completed` 文本与所有 delta 拼接结果不同,哪一个应该成为最终状态?为什么? +3. 如果 Browser 断开连接但 server 没有收到 AbortSignal,child process 会发生什么? +4. 为什么 MCP result 应该采用 allow-list mapping,而不是 `...rawItem`? +5. Runtime Supervisor 引入后,哪些状态应该进 supervisor,哪些仍应保持 request-scoped? +6. 什么时候 process-per-turn 的简单性比长连接性能更重要? -- 2.54.0 From 75270da531a0ca3e09059eecb8e8a5143a781a52 Mon Sep 17 00:00:00 2001 From: CoderLambert Date: Fri, 11 Sep 2026 21:04:53 +0800 Subject: [PATCH 3/3] docs: deepen tanstack react streaming notes from source --- ...nstack-streaming-state-source-deep-dive.md | 1074 +++++++++++++++++ 1 file changed, 1074 insertions(+) create mode 100644 docs/notes/03-tanstack-streaming-state-source-deep-dive.md diff --git a/docs/notes/03-tanstack-streaming-state-source-deep-dive.md b/docs/notes/03-tanstack-streaming-state-source-deep-dive.md new file mode 100644 index 0000000..98bad49 --- /dev/null +++ b/docs/notes/03-tanstack-streaming-state-source-deep-dive.md @@ -0,0 +1,1074 @@ +# TanStack Start + React Streaming:源码驱动的状态与 Hook 深挖 + +> 基线:`main@294ecae6` +> +> 本文从 `createServerFn → async generator → useChatController → ChatEvent Adapter → chatReducer → localStorage → ChatPage` 的真实链路提炼知识。目标不是背 Hook API,而是理解每个 API 为什么存在、何时不该用、生产环境如何使用。 + +--- + +## 1. 完整数据链路:流式 UI 不是 React 自己“流”出来的 + +```text +ChatInput + ↓ onSend +useChatController.sendMessage() + ↓ +streamChat() / createServerFn + ↓ +async generator on server + ↓ +streamNormalizedChatEvents() + ↓ +ChatEvent + ↓ for await...of +ChatStateEvent + ↓ dispatch +chatReducer() + ↓ +ChatState + ↓ +React render +``` + +真正的流式成立需要每一层都保持增量语义。 + +任何一层如果写成: + +```ts +const all = [] +for await (const event of source) { + all.push(event) +} +return all +``` + +那么前面的 Runtime 再流式,UI 也只能最后一次性看到结果。 + +### 工业级心智模型 + +```text +Provider streaming +≠ Transport streaming +≠ State incremental update +≠ Visual typing animation +``` + +四者必须区分。 + +--- + +## 2. `createServerFn`:类型安全 RPC 的价值在“边界”,不在少写一个 API Route + +源码: + +```ts +export const streamChat = createServerFn({ method: 'POST' }) + .validator(validateChatRequest) + .handler(async function* ({ data }) { + yield* streamNormalizedChatEvents(data, streamCodexTurn) + }) +``` + +### 定义 + +Server Function 让浏览器以“函数调用”的开发体验跨越 Browser/Server: + +```text +client call + ↓ framework transport +server handler +``` + +### 为什么这里需要 validator + +TypeScript 只在编译期存在。 + +真实网络边界收到的是: + +```text +unknown input +``` + +所以: + +```ts +streamChat({ data }) +``` + +即使调用方类型正确,也不能替代服务端运行时验证。 + +### 生产范式 + +每一个网络/RPC 边界默认使用: + +```text +unknown + ↓ parse / validate +trusted request type +``` + +不要把“前后端都用 TypeScript”误解成“网络数据天然可信”。 + +--- + +## 3. Async Generator:为什么它适合 Agent Streaming + +服务端: + +```ts +.handler(async function* ({ data }) { + yield* streamNormalizedChatEvents(data, streamCodexTurn) +}) +``` + +客户端: + +```ts +const events = await streamChat({ data }) + +for await (const event of events) { + dispatch(...) +} +``` + +### 定义 + +普通 async function: + +```text +Promise +``` + +Async Generator: + +```text +AsyncIterable +``` + +也就是: + +```text +0..N 个异步值 +``` + +它非常适合: + +- token/delta streaming; +- progress event; +- tool lifecycle; +- logs; +- incremental query result。 + +### 与 callback 的区别 + +Callback 风格: + +```text +producer push -> consumer +``` + +Async iterator: + +```text +consumer next() <-> producer yield +``` + +后者更容易表达: + +- 顺序; +- 生命周期; +- `try/finally` cleanup; +- cooperative backpressure。 + +--- + +## 4. `useReducer`:为什么它比一堆 `useState` 更适合 Agent UI + +当前: + +```ts +const [state, dispatch] = useReducer(chatReducer, initialChatState) +``` + +状态: + +```ts +interface ChatState { + threadId: string | null + messages: ChatMessage[] + activities: AgentActivity[] + status: 'idle' | 'running' | 'error' + error: string | null +} +``` + +### 定义 + +Reducer: + +```text +State + Action -> Next State +``` + +### 为什么适合流式 Agent + +这里的状态不是几个互不相关的值,而是一个状态机: + +```text +Idle + ↓ turn.started +Running + ├─ thread.started + ├─ assistant.started + ├─ assistant.delta × N + ├─ activity.* + ├─ assistant.completed + └─ turn.completed / error +``` + +如果拆成: + +```ts +setMessages(...) +setStatus(...) +setActivities(...) +setError(...) +``` + +很容易出现中间非法状态,例如: + +```text +status = idle +但 activity 仍 running +``` + +Reducer 把跨字段不变量放到同一个 transition 中。 + +### 工业级范式 + +当出现以下信号时优先考虑 reducer/state machine: + +```text +多个 state 总是一起变化 +存在明确 event vocabulary +存在状态转换规则 +需要 event replay / test +``` + +不要因为“代码看起来复杂”就机械使用 reducer;简单独立表单字段继续 `useState` 更清晰。 + +--- + +## 5. Reducer 纯度:为什么 `Date.now()` 是真实的生产问题 + +当前 reducer 的 fallback 仍存在: + +```ts +createdAt: Date.now() +``` + +### 纯函数定义 + +理想 reducer: + +```text +same state + same action + ↓ +same next state +``` + +`Date.now()` 破坏这一点。 + +### 为什么影响生产,而不是代码洁癖 + +确定性 reducer 支持: + +- 单元测试; +- event replay; +- bug reproduction; +- time-travel; +- SSR/debug; +- 从 Redux/Zustand/XState 迁移。 + +如果 replay 同一个事件得到不同 timestamp,状态就不是事件的确定性投影。 + +### 正确模式 + +副作用层先产生事实: + +```ts +const receivedAt = Date.now() +``` + +再传入: + +```ts +{ + type: 'assistant.delta', + receivedAt, + ... +} +``` + +Reducer 只消费。 + +> Reducer 应转换事实,不应创造事实。 + +--- + +## 6. `useEffect`:核心不是“组件加载后执行”,而是同步外部系统 + +当前有三类 Effect。 + +### 6.1 同步 `stateRef` + +```ts +useEffect(() => { + stateRef.current = state +}, [state]) +``` + +### 6.2 首次读取 localStorage + +```ts +useEffect(() => { + const storage = getBrowserStorage() + const conversation = storage ? loadConversation(storage) : null + // restore + setRestored(true) +}, []) +``` + +### 6.3 状态变化后持久化 + +```ts +useEffect(() => { + if (!restored) return + const storage = getBrowserStorage() + if (storage) saveConversation(storage, conversationFromState(state)) +}, [restored, state.threadId, state.messages]) +``` + +### 正确定义 + +Effect 用于: + +```text +React state + ↕ synchronize +external system +``` + +外部系统包括: + +- DOM imperative API; +- network; +- subscription; +- timer; +- localStorage; +- browser API; +- third-party widget。 + +### 什么不该放 Effect + +如果只是从 props/state 计算: + +```ts +const fullName = `${firstName} ${lastName}` +``` + +不要: + +```ts +useEffect(() => setFullName(...), [firstName, lastName]) +``` + +这是派生状态反模式,会多一次 render,并产生同步问题。 + +### 工业级判断算法 + +```text +这是事件处理? -> event handler +这是纯派生值? -> render 直接计算 +这是昂贵纯计算? -> 测量后 useMemo +这是外部系统同步? -> useEffect +``` + +--- + +## 7. `useRef`:三个完全不同的生产用途 + +当前项目同时展示了三种 ref 思维。 + +### 7.1 DOM Ref + +`ChatPage`: + +```ts +const scrollAnchorRef = useRef(null) +``` + +配合: + +```ts +scrollAnchorRef.current?.scrollIntoView({ block: 'end' }) +``` + +这是 imperative DOM handle。 + +### 7.2 Latest-value Ref + +Controller: + +```ts +const stateRef = useRef(state) +``` + +让稳定 callback 读取最新 state。 + +### 7.3 Mutable control token + +```ts +const generationRef = useRef(0) +``` + +用来判断异步 stream 是否仍属于当前 conversation generation。 + +### Ref 的本质 + +```text +一个跨 render 保持 identity 的 mutable container +修改 current 不触发 render +``` + +因此它适合“控制数据”,不适合“UI 数据”。 + +如果用户界面需要因为某个值变化而更新,就应该用 state,而不是 ref。 + +--- + +## 8. `useCallback`:缓存函数引用,不缓存函数结果 + +当前: + +```ts +const sendMessage = useCallback(async (content: string) => { + // ... +}, []) +``` + +### 定义 + +```ts +useCallback(fn, deps) +``` + +返回一个在 deps 不变时保持 reference identity 的函数。 + +等价理解: + +```ts +useMemo(() => fn, deps) +``` + +### 它解决什么 + +主要解决 identity: + +```text +memoized child prop +effect dependency +external subscribe/unsubscribe handler +``` + +### 它不解决什么 + +它不会: + +- 让函数内部更快; +- 避免函数执行; +- 自动减少所有 render; +- 修复 stale closure。 + +### 当前为什么配合 `stateRef` + +空依赖 callback 捕获初始 render 的闭包。 + +为了读最新: + +```ts +stateRef.current.status +stateRef.current.threadId +``` + +### 生产避坑 + +不要为了维持 `[]` 依赖不断把所有状态搬进 ref。 + +正确问题应该是: + +```text +这个函数真的需要稳定 identity 吗? +``` + +如果答案不明确,普通函数通常更简单。 + +--- + +## 9. `useMemo`:什么时候需要,为什么当前项目不应该到处加 + +### 定义 + +```ts +const value = useMemo( + () => expensiveCompute(input), + [input], +) +``` + +缓存的是**value**。 + +### 两个主要用途 + +#### 1. 避免昂贵重复计算 + +```ts +const filtered = useMemo( + () => hugeList.filter(expensivePredicate), + [hugeList, filter], +) +``` + +#### 2. 稳定派生对象引用 + +当下游真的依赖 identity: + +```ts +const contextValue = useMemo( + () => ({ state, actions }), + [state, actions], +) +``` + +### 当前项目为什么没有明显必要 + +例如: + +```ts +conversationFromState(state) +status === 'running' +messages.length === 0 +``` + +都很廉价。 + +给它们套 `useMemo`: + +```text +增加代码 +增加 dependency 管理 +增加认知成本 +性能收益近乎 0 +``` + +### 高频 streaming 下真正的性能热点 + +更可能是: + +```text +every token delta + ↓ dispatch +new messages array + ↓ React render +markdown rendering / layout +``` + +这时更值得评估: + +- token batching; +- requestAnimationFrame flush; +- message component memoization; +- external store / selector; +- virtualization; +- markdown incremental strategy。 + +而不是先 memo 一个布尔表达式。 + +### 工业级范式 + +```text +correctness first + ↓ +Profiler + ↓ +identify hotspot + ↓ +useMemo/memo/selectors/batching + ↓ +measure again +``` + +--- + +## 10. Stale Closure:理解 Hook 的关键不是依赖数组,而是 Render Snapshot + +React 每次 render 都创建新的作用域: + +```text +Render #1 -> state A -> callback A +Render #2 -> state B -> callback B +``` + +如果保留 Render #1 创建的 callback,它看到的就是 A。 + +这就是 stale closure。 + +### 三种解决路线 + +**路线 A:正确依赖** + +```ts +useCallback(() => doSomething(state), [state]) +``` + +默认优先。 + +**路线 B:函数式 state update** + +```ts +setState(prev => next(prev)) +``` + +当只需要基于旧值更新时。 + +**路线 C:latest ref** + +```ts +ref.current = state +``` + +当 callback identity 必须长期稳定,但逻辑要读最新值时。 + +Ref 是 escape hatch,不是默认答案。 + +--- + +## 11. Generation Guard:解决 stale async result,不等于 Cancel + +源码: + +```ts +const generation = generationRef.current + +for await (const event of events) { + if (!isCurrentChatGeneration(generation, generationRef.current)) return + dispatch(...) +} +``` + +New Chat: + +```ts +generationRef.current += 1 +``` + +### 解决的问题 + +场景: + +```text +Turn A 正在流 +用户点击 New Chat +Turn A 又回来一个 delta +``` + +没有 guard:旧流污染新会话。 + +有 guard:旧 event 被丢弃。 + +### 它没有解决的问题 + +旧 Runtime 仍可能继续运行。 + +所以: + +```text +generation guard = stale result suppression +AbortController = work cancellation +``` + +生产 Agent UI 最终通常两个都要。 + +--- + +## 12. 持久化:为什么只保存 `threadId + messages` + +`conversationFromState()`: + +```ts +return { + threadId: state.threadId, + messages: state.messages, +} +``` + +没有存: + +```text +activities +status +error +``` + +### 原则 + +区分: + +```text +Durable product state +vs +Ephemeral runtime state +``` + +刷新后 `running=true` 没有意义,因为旧浏览器进程已经不存在。 + +### Version Envelope + +```ts +{ + version: 1, + conversation +} +``` + +这是生产必备思想:客户端持久化也是 schema,需要迁移策略。 + +### Runtime validation + +读取 storage 后逐字段检查: + +```text +JSON parse +version +conversation object +threadId type +messages array +message fields +``` + +因为 localStorage 也属于不可信输入: + +- 旧版本; +- 用户手工改; +- extension 修改; +- 数据损坏。 + +--- + +## 13. SSR Awareness:`window` 不是永远存在 + +源码: + +```ts +export function getBrowserStorage(): StorageLike | null { + if (typeof window === 'undefined') return null + // ... +} +``` + +TanStack Start 是全栈/SSR 环境。 + +React 文件能被服务端执行,并不意味着: + +```text +window +localStorage +document +``` + +永远存在。 + +### 工业级原则 + +Browser API 必须显式位于: + +```text +client event handler +useEffect +browser guard +client-only module +``` + +之一。 + +--- + +## 14. DOM Effect:自动滚动为什么属于 `useEffect` + +`ChatPage`: + +```ts +useEffect(() => { + scrollAnchorRef.current?.scrollIntoView({ block: 'end' }) +}, [messages, activities, status]) +``` + +这是正确 Effect 类型:React render 完成后同步真实 DOM scroll position。 + +### 生产风险 + +流式 delta 高频时,这段 Effect 可能每个 token 都触发滚动,导致: + +- layout thrashing; +- 用户手动向上阅读被强制拉回底部; +- 移动端性能下降。 + +### 工业级范式 + +成熟聊天 UI 通常需要: + +```text +用户是否在底部附近? + yes -> auto scroll + no -> 保持当前位置 + 显示“回到底部” +``` + +并可通过 rAF/throttle 限制滚动频率。 + +--- + +## 15. Immutable Update:正确性与成本模型要同时理解 + +Reducer 中: + +```ts +const messages = [...state.messages] +messages[index] = { + ...message, + content: message.content + event.delta, +} +``` + +### 为什么要新引用 + +React 的更新判断依赖 reference identity。 + +直接: + +```ts +state.messages[index].content += delta +return state +``` + +会破坏 immutable state 契约,并让调试和 memoization 失效。 + +### 但高频 delta 的成本 + +每个 token 都: + +```text +复制 messages array +创建 message object +字符串 concat +React render +DOM/Markdown update +``` + +小规模 V0 足够,但长会话可能成为热点。 + +### 演进路线 + +不要先手写复杂 mutable store。按证据升级: + +```text +1. measure +2. batch delta +3. isolate current streaming message +4. memo child messages +5. selector/external store if needed +6. virtualize long history +``` + +--- + +## 16. `ChatEvent → ChatStateEvent`:Adapter 不是重复类型 + +Transport: + +```text +服务器允许发送什么? +``` + +State Event: + +```text +Reducer 需要什么才能更新? +``` + +例如: + +```text +assistant.started transport +{id} + +assistant.started state +{id, createdAt} +``` + +Adapter 在客户端补 `receivedAt`。 + +### 为什么不合并 + +因为网络模型、领域模型、状态模型的变化原因不同。 + +如果为了少一个类型把它们合并,就把三个变化轴重新耦合。 + +这属于经典 Anti-Corruption Layer。 + +--- + +## 17. Error Handling:错误也是状态机事件 + +Controller catch: + +```ts +catch (error) { + dispatch({ + type: 'event.received', + event: { + type: 'error', + message: ..., + }, + }) +} +``` + +Reducer: + +```text +running -> error +``` + +### 为什么比 `console.error` 更正确 + +错误必须进入产品状态,UI 才能: + +- 显示; +- retry; +- disable/enable input; +- persist diagnostics id; +- recovery。 + +但详细 Runtime error 不应该直接进 Browser,因此 Server Function 先做 sanitization。 + +--- + +## 18. 工业级 Hook 选择算法 + +遇到一个需求,可以按下面顺序判断: + +```text +需要渲染它吗? + yes -> state / reducer + no + ↓ +需要跨 render 保存 mutable value 吗? + yes -> ref + no + ↓ +是纯派生计算吗? + yes -> render 直接计算 + 昂贵且已测量? -> useMemo + no + ↓ +是在响应用户事件吗? + yes -> event handler + no + ↓ +是在同步外部系统吗? + yes -> useEffect +``` + +函数是否需要 `useCallback` 再独立问: + +```text +是否有明确的 reference identity 消费者? +``` + +没有就不要默认加。 + +--- + +## 19. 调试路径 + +### UI 不更新 + +```text +ChatEvent 是否到达? +↓ +toChatStateEvent 是否映射? +↓ +Reducer 是否产生新引用? +↓ +组件是否读取了正确字段? +``` + +### UI 最后一次性更新 + +```text +Runtime 是否有 delta? +↓ +Server async generator 是否逐条 yield? +↓ +RPC 是否保留 AsyncIterable? +↓ +Controller 是否 for await 逐条 dispatch? +``` + +### New Chat 后出现旧消息 + +```text +generation 是否增加? +旧 stream 是否检查 generation? +是否还需要真正 Abort? +``` + +### 刷新丢历史 + +```text +restore effect +storage key/version +runtime validation +persist effect deps +threadId/messages 是否真的写入 +``` + +--- + +## 20. 复习检查表 + +- [ ] 能解释 Server Function 为什么仍需要 runtime validation。 +- [ ] 能说明 AsyncGenerator 与普通 Promise 的语义差异。 +- [ ] 能说明 `useReducer` 为什么适合流式状态机。 +- [ ] 能解释 reducer 内 `Date.now()` 的问题。 +- [ ] 能说清 `useEffect` 的“同步外部系统”定义。 +- [ ] 能区分 DOM ref、latest-value ref、generation ref。 +- [ ] 能解释 `useCallback` 缓存函数引用,不缓存结果。 +- [ ] 能解释 stale closure 的 Render Snapshot 根因。 +- [ ] 能说出 `useMemo` 的两个合理使用场景。 +- [ ] 能说明为什么不能用 `useMemo` 修复业务正确性。 +- [ ] 能区分 generation guard 与 Abort。 +- [ ] 能解释 localStorage 为什么也需要 schema validation。 +- [ ] 能解释 SSR 环境为什么不能随便读取 `window`。 +- [ ] 能分析 token delta 高频 render 的成本模型。 + +--- + +## 21. 思考题 + +1. 如果把 `sendMessage` 的 `useCallback([])` 删除,当前代码一定会变慢吗?如何证明? +2. 如果把 `stateRef` 去掉,应怎样改 dependency 才能保持逻辑正确? +3. `useMemo(() => messages, [messages])` 有价值吗?为什么? +4. 如果 assistant 每秒产生 100 个 delta,最优先测量哪个阶段? +5. 如果 localStorage schema 升到 v2,你会采用丢弃、迁移还是双读?选择依据是什么? +6. 如果将 reducer 改成 XState,哪些 application event contract 可以原样保留? -- 2.54.0