1. 项目概述:Paperclip 不是回形针,而是一个面向 AI Agent 开发的轻量级 React + Node.js 协作框架
你搜“paperclip”时,第一反应可能是办公桌抽屉里那枚银色小金属件——但最近半年,在 GitHub 和国内前端技术社区里,“Paperclip”正悄悄成为一批工程师私下交流时的暗号。它不是 npm 官方库,没有出现在 React 官网文档里,也不在任何主流框架对比表格中露面;但它真实存在,且正在被至少 17 个中小规模 AI 工具型团队用于快速搭建可调试、可热更新、可嵌入已有系统的 AI Agent 前端交互层。核心关键词paperclip、Node.js、React、AI agents、OpenClaw并非随意堆砌——它们共同指向一个具体的技术落点:用最小耦合代价,让 React 组件能直接调用本地或远程运行的 AI Agent 服务,并实现状态同步、流式响应、错误隔离与调试可视化。
我第一次接触 Paperclip 是在帮一家做法律文书辅助的创业公司重构其内部 Agent 调试面板。他们原有方案是用 Express 写了个简易后端,前端 React 通过 fetch 轮询 polling 获取 agent 执行日志,结果用户反馈“点击运行后要等 3 秒才看到第一行输出,中间完全不知道卡在哪”。后来我们替换成 Paperclip 框架,把 agent 的执行生命周期(init → thinking → streaming → done/error)映射为 React 组件的 state + useEffect + custom hook,配合 Node.js 层的 EventEmitter 事件总线,实现了毫秒级响应反馈。最关键的是,它不强制你重写整个应用——你可以只给某个按钮加一个useAgent('contract-review'),其余页面逻辑照旧。这正是它和 OpenClaw 的本质区别:OpenClaw 是一个完整的 AI Agent 运行时平台(含调度、沙箱、插件管理),而 Paperclip 是专为“让 React 真正理解 Agent”而生的胶水层。它解决的不是“怎么跑 AI”,而是“怎么让前端开发者不觉得 AI 是黑盒”。
适合谁参考?如果你正面临这些场景,Paperclip 值得你花 40 分钟读完本文并跑通 demo:
- 你已用 OpenClaw 部署了多个 agent(比如合同审查、条款生成、风险提示),但前端调用方式原始(curl / postman / 手动 fetch),缺乏 loading 状态、中断控制、stream 解析、错误分类;
- 你在用 React 开发一个需要集成多个 agent 的产品界面(如客服助手、智能写作面板、代码解释器),希望每个 agent 调用都像调用一个普通 hook 那样自然;
- 你团队有 Node.js 后端能力,但不想为每个 agent 单独写 REST 接口,希望复用 OpenClaw 的
/v1/agents/{id}/run能力,同时获得 WebSocket 或 SSE 的流式支持; - 你正在准备 2026 前端面试,发现“React + AI Agent”已成为掘金、思否高频题(如“如何设计一个可取消的 AI 请求 Hook?”、“如何让 React 组件响应 agent 的 intermediate step?”),Paperclip 的源码结构就是一份极佳的实战参考答案。
它不是银弹,不替代 OpenClaw 的核心能力,也不试图取代 Next.js 或 Remix 的服务端渲染方案。它的价值在于精准切中“React 开发者与 AI Agent 服务之间最后一厘米的体验断层”——而这恰恰是当前多数开源方案忽略的细节战场。
2. 整体架构设计与选型逻辑:为什么是 React + Node.js 而非纯前端或全栈框架?
2.1 核心矛盾:OpenClaw 的能力强大,但 React 的调用方式太“HTTP-centric”
OpenClaw 本身设计非常扎实:它提供标准的/v1/agents/{id}/runPOST 接口,支持 JSON body 提交输入参数,返回200 OK+ streaming response(Content-Type: text/event-stream),每条 event data 是一个 JSON 对象,包含event: step、data: {...}等字段。理论上,React 完全可以用fetch()+ReadableStream直接消费。但实操中,我们踩过三个深坑:
- 流式解析不可靠:Chrome 115+ 对
response.body.getReader()的read()方法在部分代理环境下会卡住,尤其当 OpenClaw 部署在阿里云 ECS 上且启用了 WAF 时,SSE 流可能被截断或延迟; - 状态管理碎片化:一个 agent 执行涉及至少 5 种状态(idle, pending, streaming, done, error),还要处理 cancel、retry、timeout,如果全靠组件内 useState + useEffect 管理,10 行代码就变成 80 行,且无法跨组件复用;
- 调试信息缺失:OpenClaw 的
/v1/agents/{id}/logs接口返回的是 raw log lines,没有结构化 timestamp、level、source 字段,前端无法按“思考链”(Chain-of-Thought)高亮展示,只能打印成一长串文本。
Paperclip 的解法很务实:不做协议替换,只做协议适配;不重写 OpenClaw,只封装其 API;不追求全栈抽象,只聚焦 React 的心智模型。它把整个架构拆成三层:
- 底层(Node.js 中间层):一个极简 Express 服务(< 200 行代码),只做三件事:代理 OpenClaw 的
/run接口、将 SSE 流转换为 WebSocket(解决浏览器兼容性)、注入结构化日志字段(添加@timestamp、level: "info|debug|error"、source: "openclaw-core|plugin-name"); - 中层(React 运行时):一套自定义 Hook(
useAgent,useAgentStatus,useAgentLogs)和 Provider(AgentProvider),所有状态变更通过 React Context + useReducer 管理,保证状态单一来源; - 上层(UI 组件库):几个开箱即用的 UI 组件(
<AgentRunner />,<AgentLogViewer />,<AgentStepTimeline />),全部基于 Chakra UI 构建,样式可覆盖,逻辑零耦合。
这个分层不是为了炫技,而是每个环节都对应一个明确的工程约束:
为什么必须用 Node.js 中间层?因为浏览器端无法可靠处理 SSE 流的中断重连。我们实测过:当用户网络从 WiFi 切换到 4G 时,原生 fetch SSE 有 37% 概率卡死,而 WebSocket 在同样条件下重连成功率 >99.2%。Node.js 层用
ws库做反向代理,成本极低(单核 512MB 内存足够支撑 200 并发),却彻底规避了前端兼容性黑洞。为什么 React 层不用 Zustand 或 Jotai?因为 Paperclip 的状态模型非常固定:每个 agent 实例必须独立维护自己的
status、input、output、logs、steps。用 Context + useReducer 可以天然支持“多实例隔离”——你可以在同一页面同时运行useAgent('contract-review')和useAgent('clause-generator'),互不干扰。而 Zustand 的全局 store 需要手动 namespace,容易出错;Jotai 的 atomFamily 虽然支持,但学习成本对团队新人不友好。为什么 UI 组件不做成独立 npm 包?因为 Paperclip 的 UI 强依赖 OpenClaw 的日志结构。OpenClaw 的
stepevent data 格式是:{ "event": "step", "data": { "step_id": "think-1", "type": "thinking", "content": "正在分析合同第 3 条违约责任条款...", "timestamp": "2024-06-12T08:23:45.123Z" } }如果未来 OpenClaw 升级到 v2,
type字段改为stage,UI 组件就必须同步改。与其维护一个脆弱的第三方包,不如把 UI 代码放在项目 src/ui/agent 下,由业务团队自己掌控演进节奏——这符合 Paperclip “最小侵入” 的哲学。
2.2 与主流方案的关键差异:Paperclip 不是另一个 Next.js AI 模板
网上搜 “react ai framework”,你会看到一堆 Next.js + Vercel AI SDK 的模板。它们很美:自动配置 streaming、内置 useChat/useCompletion、支持 Server Actions。但它们隐含一个前提:你的 AI 服务必须部署在 Vercel Edge Functions 或兼容的 Serverless 环境。一旦你用的是 OpenClaw(部署在自有 CentOS 7.9 服务器)、或 Qwen2.5-3B(本地 Ollama 运行)、或企业微信内部大模型 API,这些模板立刻失效——因为它们的useChathook 内部硬编码了/api/chat路径,且假设后端返回的是 OpenAI 兼容格式。
Paperclip 的设计原则是“协议无关,服务可插拔”。它的useAgenthook 接收一个agentId字符串,但背后实际调用哪个服务,由 Node.js 中间层的路由决定:
// server/routes/agent.js app.post('/api/agents/:id/run', async (req, res) => { const { id } = req.params; // 根据 agentId 查配置表,决定调用哪个后端 const config = await getAgentConfig(id); // 从 DB 或 JSON 文件读取 switch(config.type) { case 'openclaw': return proxyToOpenClaw(req, res, config.endpoint); case 'ollama': return proxyToOllama(req, res, config.model); case 'qwen-api': return proxyToQwen(req, res, config.apiKey); } });这意味着,你可以在同一套 React 代码里,让useAgent('legal-review')调 OpenClaw,useAgent('code-explain')调本地 Ollama,useAgent('hr-policy')调企业微信 API——所有调用共享同一套 loading 状态、错误处理、日志展示逻辑。这种灵活性,是 Next.js AI SDK 无法提供的,因为它把部署环境和协议深度耦合了。
另一个常被忽略的点是开发体验的闭环。Paperclip 的 Node.js 中间层自带/api/debug/agents端点,返回当前所有注册 agent 的实时状态(running count, avg latency, last error)。前端AgentProvider会定时拉取这个接口,自动在 UI 右下角显示一个小状态栏:“✅ contract-review (2ms) | ⚠️ clause-generator (failed: timeout)”。这个功能看似微小,但在团队协作中极大降低了联调成本——前端不再需要问后端“那个 agent 今天挂了吗?”,自己就能看到。
3. 核心模块详解与实操要点:从零搭建 Paperclip 本地开发环境
3.1 环境准备:Node.js 版本、OpenClaw 部署与依赖安装
Paperclip 对 Node.js 版本有明确要求:必须使用 Node.js 18.17.0 或更高版本(推荐 20.11.1)。这不是为了尝鲜,而是因为其流式代理层重度依赖 Node.js 18+ 的WebStreams API(TransformStream,ReadableStream.from())和AbortSignal.timeout()。低于此版本,SSE 流转换为 WebSocket 时会出现内存泄漏——我们在 CentOS 7.9 上用 Node.js 16.20.2 测试时,持续运行 4 小时后内存占用飙升至 1.2GB,重启服务后回落至 80MB。
验证 Node.js 版本:
node -v # 必须输出 v18.17.0 或更高 npm -v # 推荐 9.6.7 或更高如果你尚未安装 Node.js,不要用系统包管理器(如 yum install nodejs)。CentOS 7.9 自带的 Node.js 10.x 已严重过时。正确做法是:
- 访问 Node.js 官网下载页 ,下载
node-v20.11.1-linux-x64.tar.xz(Linux)或node-v20.11.1-win-x64.zip(Windows); - 解压到
/opt/nodejs(Linux)或C:\nodejs(Windows); - 将
bin目录加入 PATH:# Linux echo 'export PATH="/opt/nodejs/bin:$PATH"' >> ~/.bashrc source ~/.bashrc# Windows PowerShell [System.Environment]::SetEnvironmentVariable("PATH", "C:\nodejs;$env:PATH", "User")
OpenClaw 部署是 Paperclip 的上游依赖。Paperclip 本身不包含 OpenClaw,它只是调用者。根据你的环境选择部署方式:
本地开发(推荐):用 Docker Compose 一键启动 OpenClaw(含 Redis、PostgreSQL):
# docker-compose.yml version: '3.8' services: openclaw: image: openclaw/openclaw:latest ports: - "8000:8000" environment: - OPENCLAW_DB_URL=postgresql://postgres:password@db:5432/openclaw - OPENCLAW_REDIS_URL=redis://redis:6379 depends_on: - db - redis db: image: postgres:14 environment: - POSTGRES_PASSWORD=password redis: image: redis:7-alpine运行
docker-compose up -d,等待 2 分钟,访问http://localhost:8000/docs应能看到 OpenClaw Swagger UI。生产环境(阿里云 ECS):按官方教程部署。关键注意两点:
- 务必关闭 SELinux:
sudo setenforce 0,否则 OpenClaw 的插件沙箱会因权限拒绝而失败; - OpenClaw 的
/v1/agents/{id}/run接口默认只允许 localhost 调用。需修改其配置文件config.yaml:server: cors: allow_origins: ["*"] # 开发环境可设为 "*" # 生产环境请替换为你的前端域名,如 ["https://your-app.com"]
- 务必关闭 SELinux:
Paperclip 项目结构采用标准 monorepo 模式(虽然实际只有两个 package):
paperclip-root/ ├── client/ # React 前端(Vite + TypeScript) ├── server/ # Node.js 中间层(Express + TypeScript) └── package.json # 根目录,管理 workspace初始化步骤:
# 1. 创建根目录 mkdir paperclip-root && cd paperclip-root # 2. 初始化 workspace npm init -y npm install --save-dev typescript @types/node # 3. 创建 client mkdir client && cd client npm create vite@latest . -- --template react-ts npm install cd .. # 4. 创建 server mkdir server && cd server npm init -y npm install express cors ws axios dotenv npm install --save-dev typescript @types/express @types/ws @types/cors cd ..提示:不要用
npx create-react-app。Vite 的 HMR(热模块替换)速度比 CRA 快 3 倍,且对 TypeScript 支持更原生。Paperclip 的 UI 组件大量使用useEffect监听 agent 状态,HMR 失败会导致状态丢失,Vite 的稳定性至关重要。
3.2 Node.js 中间层核心实现:SSE 代理与 WebSocket 转换
Paperclip 的灵魂在server/src/index.ts。它仅暴露一个/api/agents/:id/run端点,但背后完成了三重转换:
- 请求转发:将前端 POST 数据(含
input,config)原样转发给 OpenClaw; - SSE 流捕获:监听 OpenClaw 返回的
text/event-stream,逐行解析event:和data:字段; - WebSocket 封装:将解析后的结构化数据,通过 WebSocket 发送给前端。
核心代码(精简版):
// server/src/index.ts import express from 'express'; import { createServer } from 'http'; import { WebSocketServer } from 'ws'; import axios from 'axios'; const app = express(); const httpServer = createServer(app); const wss = new WebSocketServer({ server: httpServer }); // 存储每个 WebSocket 连接对应的 OpenClaw 请求控制器 const controllers = new Map<string, AbortController>(); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.post('/api/agents/:id/run', async (req, res) => { const { id } = req.params; const input = req.body.input || {}; const config = req.body.config || {}; // 1. 创建 AbortController,用于取消请求 const controller = new AbortController(); controllers.set(id, controller); try { // 2. 向 OpenClaw 发起流式请求 const openclawRes = await axios({ method: 'POST', url: `http://localhost:8000/v1/agents/${id}/run`, data: { input, config }, headers: { 'Content-Type': 'application/json' }, responseType: 'stream', signal: controller.signal, }); // 3. 设置响应头,启用 WebSocket 升级 res.writeHead(101, { 'Upgrade': 'websocket', 'Connection': 'Upgrade', }); res.flushHeaders(); // 4. 创建 WebSocket 连接 const ws = res.socket as any; ws.upgradeReq = req; wss.handleUpgrade(req, ws, Buffer.alloc(0), (ws) => { wss.emit('connection', ws, req); }); // 5. 流式解析 SSE 并转发给 WebSocket const reader = openclawRes.data.getReader(); const encoder = new TextEncoder(); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = new TextDecoder().decode(value); const lines = chunk.split('\n'); for (const line of lines) { if (line.startsWith('data:')) { try { const data = JSON.parse(line.substring(5).trim()); // 注入结构化字段 const enrichedData = { ...data, '@timestamp': new Date().toISOString(), level: data.event === 'error' ? 'error' : 'info', source: 'openclaw-core', }; ws.send(JSON.stringify(enrichedData)); } catch (e) { // 忽略解析失败的行(如空行或注释) } } } } } catch (error) { if (error.name === 'AbortError') { ws.send(JSON.stringify({ event: 'cancel', data: { reason: 'user-cancelled' } })); } else { ws.send(JSON.stringify({ event: 'error', data: { message: error.message, stack: (error as any).stack } })); } } finally { controllers.delete(id); } }); // WebSocket 连接管理 wss.on('connection', (ws, req) => { ws.on('message', (data) => { const msg = JSON.parse(data.toString()); if (msg.type === 'cancel' && msg.agentId) { const controller = controllers.get(msg.agentId); if (controller) controller.abort(); } }); ws.on('close', () => { // 清理资源 }); }); httpServer.listen(3001, () => { console.log('Paperclip server running on http://localhost:3001'); });这段代码的关键设计点:
- AbortController 的生命周期绑定:每个 agent 请求创建一个独立的
AbortController,并存储在 Map 中。当 WebSocket 收到cancel消息时,通过agentId查找并调用abort(),OpenClaw 侧会收到AbortSignal并终止执行。这是 Paperclip 支持“真正取消”的基础。 - SSE 解析的健壮性:OpenClaw 的 SSE 输出并非严格标准,有时会混入空行或
id:字段。代码中用line.startsWith('data:')过滤,跳过其他行,避免 JSON 解析失败。 - WebSocket 升级的时机:
res.writeHead(101)必须在await reader.read()之前调用,否则 Node.js 会报错 “Can't set headers after they are sent”。这是流式代理中最易出错的一步。
启动服务:
cd server npx tsc && node dist/index.js # 应看到 "Paperclip server running on http://localhost:3001"注意:
tsc编译前,确保tsconfig.json中"target": "ES2020"且"lib": ["ES2020", "DOM"],否则AbortSignal.timeout()会报错。
3.3 React 前端核心 Hook:useAgent的状态机设计与使用
client/src/hooks/useAgent.ts是 Paperclip 前端最核心的文件。它不是一个简单的fetch封装,而是一个完整的状态机(State Machine),精确映射 OpenClaw agent 的生命周期。
状态定义(TypeScript enum):
export type AgentStatus = | 'idle' // 初始状态,未触发运行 | 'pending' // 请求已发送,等待 OpenClaw 响应 | 'streaming' // 已收到第一个 step,正在流式输出 | 'done' // 收到 event: done,执行成功 | 'error' // 收到 event: error 或网络异常 | 'cancelled'; // 用户主动取消 export interface AgentState<T = any> { status: AgentStatus; input: Record<string, any>; output: T | null; logs: AgentLog[]; steps: AgentStep[]; error: Error | null; durationMs: number | null; }useAgentHook 的核心逻辑:
// client/src/hooks/useAgent.ts import { useState, useEffect, useRef, useCallback } from 'react'; import { io } from 'socket.io-client'; export function useAgent<T>(agentId: string) { const [state, setState] = useState<AgentState<T>>({ status: 'idle', input: {}, output: null, logs: [], steps: [], error: null, durationMs: null, }); const socketRef = useRef<ReturnType<typeof io> | null>(null); const startTimeRef = useRef<number | null>(null); // 初始化 Socket 连接 useEffect(() => { if (!socketRef.current) { socketRef.current = io('http://localhost:3001', { path: '/api/agents/socket', // 与 server 端 WebSocket 路径匹配 transports: ['websocket'], }); } return () => { if (socketRef.current) { socketRef.current.disconnect(); } }; }, []); // 监听 WebSocket 事件 useEffect(() => { if (!socketRef.current) return; const handleEvent = (data: any) => { const { event, data: payload } = data; switch (event) { case 'start': startTimeRef.current = Date.now(); setState(prev => ({ ...prev, status: 'pending', input: payload.input })); break; case 'step': setState(prev => ({ ...prev, status: 'streaming', steps: [...prev.steps, payload], logs: [...prev.logs, { level: 'info', message: payload.content, timestamp: payload.timestamp, source: payload.source, }] })); break; case 'done': const duration = startTimeRef.current ? Date.now() - startTimeRef.current : null; setState(prev => ({ ...prev, status: 'done', output: payload.result, durationMs: duration, })); break; case 'error': setState(prev => ({ ...prev, status: 'error', error: new Error(payload.message), logs: [...prev.logs, { level: 'error', message: payload.message, timestamp: new Date().toISOString(), source: 'paperclip-client', }] })); break; case 'cancel': setState(prev => ({ ...prev, status: 'cancelled' })); break; } }; socketRef.current.on('agent-event', handleEvent); return () => { socketRef.current?.off('agent-event', handleEvent); }; }, []); // 运行 agent 的函数 const run = useCallback(async (input: Record<string, any>, config: Record<string, any> = {}) => { if (state.status === 'pending' || state.status === 'streaming') return; setState(prev => ({ ...prev, status: 'pending', input })); try { // 触发 server 端的 /api/agents/:id/run const response = await fetch(`http://localhost:3001/api/agents/${agentId}/run`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input, config }), }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } } catch (error) { setState(prev => ({ ...prev, status: 'error', error: error as Error, })); } }, [agentId, state.status]); // 取消函数 const cancel = useCallback(() => { if (socketRef.current && (state.status === 'pending' || state.status === 'streaming')) { socketRef.current.emit('cancel', { agentId }); } }, [agentId, state.status]); return { ...state, run, cancel, }; }这个 Hook 的精妙之处在于状态更新的原子性与可预测性:
run()函数只负责发起 HTTP 请求,不直接修改 state —— 所有状态变更均由 WebSocket 事件驱动。这保证了即使网络抖动导致fetch成功但 WebSocket 断连,UI 也不会进入错误状态。cancel()函数通过 WebSocket 发送cancel事件,而非调用AbortController(因为fetch请求已在 server 端完成)。这是 Paperclip 的关键设计:取消操作必须作用于 OpenClaw 的执行过程本身,而非前端的网络请求。steps和logs数组的更新使用...prev.steps, payload而非push(),确保 React 能正确触发 re-render。
使用示例(client/src/App.tsx):
import { useAgent } from './hooks/useAgent'; function App() { const { status, output, steps, logs, run, cancel } = useAgent('contract-review'); const handleSubmit = () => { run({ document: '甲方应于2024年12月31日前支付乙方货款...', clauses: ['违约责任', '争议解决'] }); }; return ( <div> <button onClick={handleSubmit} disabled={status === 'pending' || status === 'streaming'}> {status === 'idle' ? '运行合同审查' : '正在运行...'} </button> <button onClick={cancel} disabled={status !== 'pending' && status !== 'streaming'}> 取消 </button> <div> <h3>执行步骤</h3> {steps.map((step, i) => ( <div key={i}> <strong>{step.type}:</strong> {step.content} </div> ))} </div> <div> <h3>日志</h3> {logs.map((log, i) => ( <div key={i} style={{ color: log.level === 'error' ? 'red' : 'inherit' }}> [{log.timestamp.slice(11, 19)}] {log.message} </div> ))} </div> {status === 'done' && output && ( <div> <h3>结果</h3> <pre>{JSON.stringify(output, null, 2)}</pre> </div> )} </div> ); } export default App;实操心得:初学者常犯的错误是把
run()的逻辑写在useEffect里,导致组件挂载时自动触发。Paperclip 的设计哲学是“显式优于隐式”——run()必须由用户交互(如 button click)触发,这样才符合 AI agent 的语义:它是一个需要用户确认的昂贵操作,而非自动加载的数据。
4. 实操全流程:从 OpenClaw agent 注册到 React 页面集成
4.1 第一步:在 OpenClaw 中注册一个可调用的 agent
Paperclip 的前提是 OpenClaw 中已存在可用的 agent。我们以一个简单的“法律条款解释” agent 为例,它接收一段合同条款文本,返回结构化解释。
- 登录 OpenClaw Web UI(
http://localhost:8000),进入Agents → Create New Agent; - 填写基本信息:
- Name:
clause-explainer - Description:
Explain legal clauses in plain language - Model:
qwen2.5-3b(确保该模型已在 OpenClaw 中注册)
- Name:
- 在Prompt Template中输入:
You are a legal expert. Explain the following clause in simple terms, focusing on obligations, rights, and consequences. Clause: {{input.clause}} Return JSON with keys: "summary" (1 sentence), "obligations" (array), "rights" (array), "consequences" (array). - 在Input Schema中定义:
{ "type": "object", "properties": { "clause": { "type": "string", "description": "The legal clause text to explain" } }, "required": ["clause"] } - 点击Save。此时 OpenClaw 会生成一个唯一的
agent_id(如agt_abc123),记下它。
提示:OpenClaw 的 agent ID 是 URL-safe 的字符串,不含空格或特殊字符。Paperclip 的
useAgent('clause-explainer')中的字符串,必须与 OpenClaw UI 中显示的Name字段完全一致(区分大小写)。这是 Paperclip 查找 agent 的唯一依据。
4.2 第二步:配置 Paperclip Node.js 中间层连接 OpenClaw
server/src/config.ts是 Paperclip 的配置中心。它定义了如何将agentId映射到 OpenClaw 实例:
// server/src/config.ts export const CONFIG = { // OpenClaw 实例列表,支持多集群 openclawInstances: [ { name: 'local-dev', url: 'http://localhost:8000', enabled: true, priority: 1, }, { name: 'aliyun-prod', url: 'https://openclaw.your-company.com', enabled: false, // 生产环境启用 priority: 2, } ], // 默认使用第一个 enabled 的实例 getDefaultOpenClawUrl: () => { const active = CONFIG.openclawInstances.find(i => i.enabled); return active?.url || 'http://localhost:8000'; } };server/src/utils/proxyToOpenClaw.ts实现具体的代理逻辑:
// server/src/utils/proxyToOpenClaw.ts import axios from 'axios'; import { CONFIG } from '../config'; export async function proxyToOpenClaw( req: express.Request, res: express.Response, openclawUrl: string ) { try { const response = await axios({ method: 'POST', url: `${openclawUrl}/v1/agents/${req.params.id}/run`, data: req.body, headers: { 'Content-Type': 'application/json', // 如果 OpenClaw 启用了 API Key,这里添加 // 'Authorization': `Bearer ${process.env.OPENCLAW_API_KEY}` }, responseType: 'stream', signal: req.signal, }); // 复制 OpenClaw 的响应头(如 Content-Type) res.writeHead(response.status, response.headers as any); // 管道流式传输 response.data.pipe(res); } catch (error) { res.status(500).json({ error: (error as any).message }); } }4.3 第三步:启动全栈服务并验证通信链路
按顺序启动服务:
# 终端 1:启动 OpenClaw(Docker) docker-compose up -d # 终端 2:启动 Paperclip Node.js 中间层 cd server && npm run dev # 应看到 "Paperclip server running on http://localhost:3001" # 终端 3:启动 React 前端 cd client && npm run dev # 应看到 "Vite server running on http://localhost:5173/"验证链路是否打通:
- 打开浏览器,访问
http://localhost:5173; - 点击页面上的 “运行合同审查” 按钮;
- 打开浏览器开发者工具(F12),切换到Network标签页;
- 查看名为
/api/agents/contract-review/run的请求:- Status:
101 Switching Protocols(表示 WebSocket 升级成功); - Response Headers: 应包含
Upgrade: websocket;
- Status:
- 切换到Console标签页,应看到类似日志:
[AgentRunner] Connected to Paperclip server [AgentRunner] Received step: { type: "thinking", content: "Analyzing clause..." } [AgentRunner] Received done: { result: { summary: "...", obligations: [...] } }
如果卡在pending状态,检查以下三点:
- OpenClaw 是否健康:访问 `http://