Skip to content

Optional: Web UI (SSE Streaming)

Official counterpart: dsh web + apps/web (browser app)

The most recognizable official experience is the browser UI started by npx @deepseek-ai/dsh web. mini-dsh implements a zero-dependency version with node:http — one page + one SSE endpoint, and the browser chats with the agent with incremental rendering, just like the official product.

Architecture: Two Key Designs

  1. createChatServer(ctx, agent) is independently testable — pass in an assembled context and agent, get back an HTTP server. Tests can boot it with a ScriptedProvider and verify the full SSE stream.
  2. /api/chat is an SSE event stream — not "send once, wait for everything", but:
text
data: {"delta":"你"}              ← streaming delta
data: {"delta":"好"}
data: {"delta":"!"}
data: {"done":true,"content":"你好!"}  ← turn finished, with the full final text
data: {"error":"..."}             ← error (the channel stays open)

Backend: Subscribe to Events, Push in Real Time

assistant/chunk is the very event broadcast by the Agent loop — the backend only does subscribe to event → write to the response stream:

ts
/**
 * web:最简 Web UI(node:http,零依赖)+ SSE 流式输出。
 * 对应官方 `dsh web`:浏览器里与 agent 对话,边生成边渲染。
 *
 * /api/chat 返回 text/event-stream:
 *   data: {"delta":"你"}       增量内容
 *   data: {"delta":"好"}       增量内容
 *   data: {"done":true,...}    回合结束(含最终全文)
 *   data: {"error":"..."}      出错
 */

import { createServer, type Server } from "node:http";
import type { Agent } from "./agent.ts";
import type { Context } from "./context.ts";
import { buildAgent, type BootOptions } from "../boot.ts";

const PAGE = `<!doctype html>
<html lang="zh">
<head><meta charset="utf-8"><title>mini-dsh</title>
<style>body{font-family:system-ui,sans-serif;max-width:720px;margin:40px auto;padding:0 16px}
#log{white-space:pre-wrap;border:1px solid #ddd;border-radius:8px;padding:16px;min-height:200px;max-height:60vh;overflow-y:auto}
input{width:70%;padding:8px}button{padding:8px 16px}.dim{color:#888}</style></head>
<body>
<h1>mini-dsh</h1><div id="log"><span class="dim">对话记录会显示在这里…</span></div>
<p><input id="msg" placeholder="输入任务,回车发送" autocomplete="off"><button onclick="send()">发送</button></p>
<script>
async function send(){
  const msg=document.getElementById('msg');
  if(!msg.value)return;
  const log=document.getElementById('log');
  if(log.firstChild.classList)log.firstChild.remove();
  log.textContent+='\\n你> '+msg.value+'\\nmini-dsh> ';
  msg.value=''; msg.disabled=true;
  try{
    const r=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({message:msg.value})});
    if(!r.ok||!r.body)throw new Error('HTTP '+r.status);
    // 流式读取 SSE:data: {"delta":"..."} / {"done":true} / {"error":"..."}
    const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
    for(;;){
      const {done,value}=await reader.read(); if(done)break;
      buf+=dec.decode(value,{stream:true});
      const lines=buf.split('\\n'); buf=lines.pop()??'';
      for(const line of lines){
        const t=line.trim(); if(!t.startsWith('data:'))continue;
        const ev=JSON.parse(t.slice(5).trim());
        if(ev.delta)log.textContent+=ev.delta;
        if(ev.error){log.textContent+='\\n[错误] '+ev.error+'\\n';break;}
        if(ev.done)log.textContent+='\\n';
        log.scrollTop=log.scrollHeight;
      }
    }
  }catch(e){
    log.textContent+='\\n[请求失败] '+e.message+'\\n';
  }finally{ msg.disabled=false; }
  log.scrollTop=log.scrollHeight;
}
msg.addEventListener('keydown',e=>{if(e.key==='Enter')send();});
</script></body></html>`;

/** 独立可测:给定已组装好的 ctx + agent,返回 HTTP 服务器 */
export function createChatServer(ctx: Context, agent: Agent): Server {
  return createServer(async (req, res) => {
    if (req.url === "/" || req.url === "/index.html") {
      res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
      res.end(PAGE);
      return;
    }
    if (req.url === "/api/chat" && req.method === "POST") {
      let body = "";
      for await (const chunk of req) body += chunk;
      const { message } = JSON.parse(body);

      res.writeHead(200, {
        "Content-Type": "text/event-stream; charset=utf-8",
        "Cache-Control": "no-cache",
        Connection: "keep-alive",
      });
      res.flushHeaders();

      // 订阅流式增量,实时推送(与 CLI 打字机同款事件)
      const off = ctx.on("assistant/chunk")(({ delta }: any) => {
        if (typeof delta.content === "string") {
          res.write(`data: ${JSON.stringify({ delta: delta.content })}\n\n`);
        }
        if (delta.reasoning_content) {
          res.write(`data: ${JSON.stringify({ reasoning: delta.reasoning_content })}\n\n`);
        }
      });
      try {
        const reply = await agent.turn(String(message ?? ""));
        res.write(`data: ${JSON.stringify({ done: true, content: reply.content })}\n\n`);
      } catch (err) {
        res.write(`data: ${JSON.stringify({ error: err instanceof Error ? err.message : String(err) })}\n\n`);
      } finally {
        off();
        res.end();
      }
      return;
    }
    res.writeHead(404);
    res.end();
  });
}

export async function web(port = 3080, opts: BootOptions = {}): Promise<void> {
  const { ctx, agent } = await buildAgent(opts);
  createChatServer(ctx, agent).listen(port, () => {
    console.log(`mini-dsh web: http://127.0.0.1:${port}`);
  });
  const shutdown = async () => {
    await ctx.stop();
    process.exit(0);
  };
  process.on("SIGINT", shutdown);
  process.on("SIGTERM", shutdown);
}
ts
res.writeHead(200, {
  "Content-Type": "text/event-stream; charset=utf-8",
  "Cache-Control": "no-cache",
  Connection: "keep-alive",
});
res.flushHeaders();

// subscribe to streaming deltas and push in real time (same event as the CLI typewriter)
const off = ctx.on("assistant/chunk")(({ delta }: any) => {
  if (typeof delta.content === "string") {
    res.write(`data: ${JSON.stringify({ delta: delta.content })}\n\n`);
  }
});
try {
  const reply = await agent.turn(String(message ?? ""));
  res.write(`data: ${JSON.stringify({ done: true, content: reply.content })}\n\n`);
} finally {
  off();
  res.end();
}

Critical details

  • SSE needs flushHeaders() before writing data, or the browser never receives the first packet
  • Subscribe before agent.turn(), dispose after (off()) — use it and lose it, no listener leaks
  • On turn errors, write an error event instead of closing the connection, so the frontend can show a friendly message

Frontend: fetch + ReadableStream Parsing SSE

The page reads the response body with getReader(), parses data: events line by line:

js
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = "";
for (;;) {
  const { done, value } = await reader.read();
  if (done) break;
  buf += dec.decode(value, { stream: true });
  const lines = buf.split("\n");
  buf = lines.pop() ?? "";            // keep the incomplete trailing line for the next round
  for (const line of lines) {
    const t = line.trim();
    if (!t.startsWith("data:")) continue;
    const ev = JSON.parse(t.slice(5).trim());
    if (ev.delta) log.textContent += ev.delta;   // append character by character
    if (ev.done) log.textContent += "\n";
  }
}

Note the buffer handling: buf may hold a partial line, kept for the next round — the same trick as the backend parser in chapter 05, just moved into the browser.

Run

bash
pnpm web
# mini-dsh web: http://127.0.0.1:3080

Open http://127.0.0.1:3080, type a task and press Enter — you'll see the answer stream in word by word. Session state lives in the Agent's built-in session, so multi-turn conversations automatically carry history (projected via deriveMessages()).

Why port 3080?

The official dsh web defaults to 3080 — a little homage in mini-dsh.

Tests: The SSE Protocol Is Really Asserted

test/web.test.ts starts a real HTTP server and verifies the whole stream:

ts
it("/api/chat streams deltas over SSE and ends with a done event", async () => {
  // ...scripted provider, real port...
  const res = await fetch(`http://127.0.0.1:${port}/api/chat`, { method: "POST", ... });
  expect(res.headers.get("content-type")).toContain("text/event-stream");
  const events = text.split("\n").filter(l => l.trim().startsWith("data:")).map(JSON.parse);
  expect(deltas.join("")).toBe("你好,这是流式回答!");  // deltas reassemble completely
  expect(deltas.length).toBeGreaterThan(1);            // genuinely streaming, not one-shot
  expect(done.done).toBe(true);                        // proper close
});

Next Steps

  • Multi-session: add a session_id param to /api/chat, look up sessions by id in ctx.sessions
  • Reasoning display: the backend already pushes reasoning_content; the frontend can render it as grey "thinking" text
  • Stop button: AbortController to cancel a turn

Recap

  • The SSE upgrade = subscribe to assistant/chunk → write to the response stream; zero core changes
  • Frontend ReadableStream parsing; the half-line buffer is the only gotcha
  • createChatServer is independently testable; the SSE protocol is asserted by real tests

Next: Deploy to GitHub Pages →

基于 MIT 许可的 deepseek-ai/deepseek-harness 设计理念 · 本教程为独立教学项目,与 DeepSeek 官方无隶属关系