深色模式
第六步 · React 前端:聊天与事件面板
目标:把会话日志渲染成两个视图——聊天流与实时事件面板。对应文件:
final-project/apps/web/src/App.tsx、api.ts、styles.css。
6.1 一个刻意的设计选择:日志即视图
很多聊天前端把"消息列表"存在前端 state 里。mini-harness 的 React 前端不维护消息列表——它只维护一份 events(会话日志的投影),聊天流与事件面板都是它的渲染:
tsx
// 聊天流 = 按 turn 分组的事件序列
const turns = groupByTurn(events)
// 事件面板 = 同一份 events 的原样列表
{events.map((e) => <EventRow key={`${e.seq}-${e.event}`} event={e} />)}这正是 原理篇 07 的"一次写入、多方读取"在 UI 层的兑现:切换会话 = 重新拉取日志 = 视图自动重建,不需要任何"同步两份 state"的代码。真实 dsh 的 Web UI 从 session/event 渲染,是同一原则。
6.2 流式消费:fetch + ReadableStream
api.ts 的 streamMessage 手写 SSE 消费(零依赖):
ts
const reader = res.body.getReader()
let buffer = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? '' // 最后一行可能不完整,留到下一轮
for (const line of lines) {
if (!line.trim().startsWith('data:')) continue
onEvent(JSON.parse(line.trim().slice(5).trim()))
}
}两个细节:跨 chunk 断行(buffer 缓存不完整行)与 SSE 分帧(data: 前缀 + 空行分隔)。真实生产里 eventsource-parser 就是做这件事的库——dsh 的 deepseek 适配器用它(源码拆解 5.4)。
6.3 三个视图组件
TurnView:把事件按 turn/start 分组,映射每种事件到一种 UI:
| 事件 | UI |
|---|---|
user/message | 右侧蓝色气泡 |
assistant/chunk 的 text-delta | 流式文本(打字机效果的直接来源) |
tool/call | 黄色左边框卡片(🛠 名称 + 原始参数) |
tool/result | 绿色/红色卡片(✓/⚠ + 渲染内容) |
turn/end | 灰色小字(reason) |
EventRow:事件面板的行(seq + 类型 + 摘要),类型着色区分(turn 黄、工具绿、chunk 弱化)。
Composer:Enter 发送 / Shift+Enter 换行;发送期间显示 ▍ 思考中…。
6.4 与 dsh Web 架构的对照
读到这里你可能想起源码拆解 01提到的 dsh Web 结构:host 进程注入 window.__DSH_BOOT__,浏览器壳按图加载插件。mini-harness 前端没有走到那一步——它是个普通 SPA + REST/SSE。这个差异恰恰说明了"插件化可以发生在哪一层":
dsh 把"一切皆插件"贯彻到了 UI 层(Chat node、工具卡片、设置页都是浏览器侧插件),mini-harness 只在服务端保留了这个理念,前端选择最朴素的 React——这是有意的简化,扩展方向见扩展篇。
6.5 亲手做实验
实验 1:看打字机效果的来源
打开事件面板,发送一条消息,观察 assistant/chunk 的 text-delta 与聊天流文本的同步——它们是同一个事件的两处渲染。
实验 2:切换会话
发送消息后切到另一个会话再切回来。观察事件面板:视图从 GET /events 全量重建。这就是"日志即视图"。
实验 3:调 CSS 变卡片
把 styles.css 里 .tool-card.call 的左边框颜色改成品牌色,热更新立即可见——UI 完全由你自己掌控。