DevFlow Harness 全栈实践 · M1:Harness 骨架与 SSE 流式对话

59 阅读5分钟

01-skeleton.png

线上 Demo: flow.houmq.cn/


前言

DevFlow Harness 是我打算长期做的一个 Web 研发工作台——PM 写需求、前后端改代码、QA 点测,都在浏览器里完成,不是 IDE 插件。参考了 Pi Agent 的 Session + Tools,以及 DeepSeek Harness 的插件化思路。

M1 还没上插件、Agent、文件工具这些。这次就先把 能聊天的 Web 壳子 搭出来,并且 部署到公网,后面所有能力都往这个壳子里挂。

具体要完成这几件事:

  1. FastAPI 后端:/health、/chat、/chat/stream(SSE 流式)
  2. React 前端:流式打字机、Markdown 渲染、停止生成、清空对话
  3. 对话历史存 localStorage,刷新不丢
  4. Docker 双容器 + GitHub Actions 自动部署到 flow.houmq.cn

一、技术选型

层面技术选择理由
后端FastAPI + OpenAI SDKPython AI 生态顺手,自带 OpenAPI,M1 体量够轻
前端React 18 + TypeScript + Vite我日常主力栈,热更新快
流式协议SSEAI 回复是单向流,HTTP 就能搞定,调试也方便
Markdownreact-markdownassistant 消息直接渲染,不用自己写 parser
本地联调Vite proxy前端始终请求 /api/*,和生产路径一致
部署Docker Compose + Nginx前后端各一个容器,本地和生产同一套 compose
模型OpenAI 兼容 APIDeepSeek / OpenAI 换 .env 就行

M1 没做用户登录、没做数据库存会话——那些放到 M8 角色协作再说。现在对话历史只存浏览器本地。


二、项目架构

devflow-harness/
├── backend/
│   ├── main.py                 # FastAPI:/health、/chat、/chat/stream
│   ├── requirements.txt
│   ├── Dockerfile.server
│   └── .env.example
├── frontend/
│   ├── src/
│   │   ├── pages/ChatPage.tsx  # 聊天页(消息列表 + 输入框 + 停止/清空)
│   │   ├── lib/
│   │   │   ├── streamChat.ts   # fetch + ReadableStream 读 SSE
│   │   │   └── storage.ts      # localStorage 读写
│   │   └── types/chat.ts
│   ├── nginx.conf              # 容器内:/api/ 反代 backend
│   ├── vite.config.ts          # 开发态 /api proxy
│   └── Dockerfile.server
├── deploy/
│   ├── nginx-flow.houmq.cn.conf  # 主机 Nginx → 127.0.0.1:8082
│   └── README.md
├── docker-compose.yml          # 本地 / 生产通用
├── docker-compose.server.yml     # 服务器用(只拉镜像不 build)
├── .github/workflows/deploy.yml
└── docs/architecture.md

本地开发: 浏览器 :5173 → Vite 把 /api 转到 FastAPI :8000 → LLM API

生产: 浏览器 → 主机 Nginx :80 → frontend 容器 :8082 → 容器内 Nginx 把 /api/ 转到 backend :8000


三、核心功能实现

3.1 后端:SSE 流式接口

/chat/stream 接收 message + history,调 OpenAI SDK 的 stream=True,逐 chunk 往外推。

OpenAI Python SDK 的 stream 返回的是 同步迭代器,所以我用普通的 def generate() 包一层,没有硬上 async——混用 async/sync 容易出 chunk 缓冲,字一块一块蹦出来。

# backend/main.py
def generate() -> Iterator[str]:
    for chunk in stream:
        delta = chunk.choices[0].delta.content
        if delta:
            yield f"data: {json.dumps({'content': delta}, ensure_ascii=False)}\n\n"
    yield "data: [DONE]\n\n"

return StreamingResponse(
    generate(),
    media_type="text/event-stream",
    headers={
        "Cache-Control": "no-cache",
        "Connection": "keep-alive",
        "X-Accel-Buffering": "no",  # 告诉 Nginx 别缓冲
    },
)

/chat 是非流式接口,调试用;线上主要走 /chat/stream。

3.2 前端:fetch 读 SSE,不用 EventSource

EventSource 只能 GET,带不了 history 的 POST body。所以前端用 fetch('/api/chat/stream') + response.body.getReader(),自己按 \n\n 切 SSE 帧。

// frontend/src/lib/streamChat.ts
const response = await fetch('/api/chat/stream', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ message, history }),
  signal,  // 绑 AbortController,用于停止生成
})

const reader = response.body.getReader()
// ... 按 data: {...}\n\n 解析,onChunk 增量更新 UI

Vite 开发态的 proxy 配置:

// frontend/vite.config.ts
proxy: {
  '/api': {
    target: 'http://127.0.0.1:8000',
    changeOrigin: true,
    rewrite: (path) => path.replace(/^\/api/, ''),
  },
},

前端请求 /api/chat/stream,到后端实际是 /chat/stream。

3.3 聊天页:流式渲染 + 停止 + 持久化

ChatPage.tsx 里,用户发送后立刻插入一条空的 assistant 消息,每收到 chunk 就 append 到这条消息的 content 上,配合 react-markdown 实时渲染。

停止生成:AbortController 绑到 fetch 的 signal,点「停止」就 abort()。catch 到 AbortError 时 保留已经输出的内容,不 rollback。

持久化:useEffect 监听 messages 变化,写入 localStorage(key: devflow-harness-m1-chat)。M1 不做服务端存储,够用了。


四、Docker 容器化部署

4.1 双容器结构

# docker-compose.yml(节选)
services:
  backend:
    build: ./backend
    expose: ["8000"]
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; ..."]
  frontend:
    build: ./frontend
    ports: ["8082:80"]
    depends_on:
      backend:
        condition: service_healthy

frontend 镜像多阶段构建:Node 打包 → Nginx 托管静态文件 + 反代 API。

4.2 Nginx 两处配置

容器内(frontend/nginx.conf)—— /api/ 转发到 backend:

location /api/ {
    proxy_pass http://backend:8000/;   # 注意末尾斜杠,/api/chat → /chat
    proxy_buffering off;
    proxy_read_timeout 3600s;
}

主机上(deploy/nginx-flow.houmq.cn.conf)—— 域名指到容器:

location / {
    proxy_pass http://127.0.0.1:8082;
    proxy_buffering off;
    proxy_read_timeout 3600s;
}

SSE 流式如果一坨一坨出来,先查 proxy_buffering off 有没有开——主机 Nginx 和容器 Nginx 都要关。

4.3 GitHub Actions 自动部署

main push 触发:

  1. Ubuntu runner 构建 backend / frontend 镜像,打成 tar 包
  2. Mac self-hosted runner 加载镜像,SSH 到服务器
  3. 同步 docker-compose.server.yml,执行 docker compose up -d --no-build

服务器目录:/home/ubuntu/devflow-harness,.env 里配好 OPENAI_API_KEY、OPENAI_BASE_URL、MODEL。

本地一键:

cp .env.docker.example .env
chmod +x scripts/docker-deploy.sh
./scripts/docker-deploy.sh
# → http://localhost:8082

五、项目成果

功能清单

  • ✅ FastAPI 后端 /health、/chat、/chat/stream
  • ✅ React 流式打字机 + Markdown 渲染
  • ✅ 停止生成(AbortController)
  • ✅ 清空对话
  • ✅ localStorage 持久化
  • ✅ Docker 双容器 + docker-compose
  • ✅ GitHub Actions 自动部署
  • ✅ 公网 Demo:flow.houmq.cn/

技术亮点

  1. 开发 / 生产 API 路径统一 — 都走 /api/*,Vite proxy 和容器 Nginx 各管一段
  2. SSE 而非 WebSocket — M1 单向流够用,WebSocket 留给后面 QA 实时事件
  3. 同步 generate 包 SSE — 跟着 OpenAI SDK 的同步 stream 走,少踩 async 坑
  4. 双 Nginx + 关 buffering — 公网流式不卡顿的关键配置

项目截图

02-demo-screenshot.png


六、下一里程碑计划(M2)

M1 把聊天壳子和部署跑通了。M2 开始往 Harness 内核长肉:

  1. Session 工作区 — 一个项目一个 Session,不再只有单页聊天
  2. Agent Loop — 多轮推理循环
  3. 读 / 写文件 — Agent 第一次能碰磁盘

插件协议 deliberately 往后放(M9),等工具都跑真了再收编,避免先搭空框架。详见仓库 docs/roadmap.md。


七、总结

M1 看起来就是个 ChatGPT 网页,但对我这个项目的意义是:前后端 + SSE + Docker + CI/CD + 公网域名 整条线通了。后面加 Session、加文件工具、加预览 iframe,都是在已有壳子上叠功能,不用重新折腾部署。

最大的收获是搞清楚了 SSE 在 Nginx 双层反代下怎么才不缓冲——X-Accel-Buffering: no 和 proxy_buffering off 两处都得配。

教训是 M1 功能砍得够狠才好上线:没登录、没数据库、没插件,但一周之内就能有个能用的公网 Demo,比一上来设计 Plugin Registry 实用多了。


附录:项目地址