Skip to content

第六步 · React 前端:聊天与事件面板

目标:把会话日志渲染成两个视图——聊天流与实时事件面板。对应文件:final-project/apps/web/src/App.tsxapi.tsstyles.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.tsstreamMessage 手写 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 完全由你自己掌控。

基于 DeepSeek Harness(开发者预览版 0.1.0-rc.6)与 Cordis 撰写