1. 项目概述:Paperclip 不是回形针,而是一个被严重误读的 AI 工具链命名陷阱
“Paperclip”这个词一出来,90% 的人第一反应是办公桌抽屉里那个银色金属弯钩——回形针。但在这个技术语境下,它根本不是物理物件,而是一个典型的命名混淆型项目代号。它既不是 npm 上已发布的知名库(查过 registry,无 paperclip 主包),也不是 React 官方生态里的组件名,更不是 Node.js 的内置模块。它出现在近期开发者社区讨论中,高频绑定在 OpenClaw、Claude Code、React 前端集成等关键词旁,实际指向的是一个本地化部署的轻量级 AI 工具胶水层(glue layer):用 Node.js 编写的服务端桥接器,负责将 Claude Code 的本地推理能力(通过其 CLI 或 Desktop API)与前端 React 应用进行低延迟、状态可控的通信封装,并为 OpenClaw 提供标准化的插件调用入口。
我第一次看到这个词是在一个掘金帖子的评论区:“paperclip 启动后 openclaw 报 connect ECONNREFUSED”,当时就意识到——这绝不是某个现成开源项目的直接引用,而是团队内部对“胶水服务”的临时命名。就像当年 Facebook 内部叫 React “FaxJS”,后来才正式定名一样,“Paperclip”在这里的隐喻非常精准:它不承载核心逻辑,也不暴露复杂接口,只做一件事——把散落的、异构的 AI 工具(Claude Code 的本地模型调用、OpenClaw 的插件管理、React 的 UI 状态)像回形针一样轻轻夹在一起,保持各自独立,又确保它们能协同工作。这个命名背后藏着一个关键设计哲学:拒绝大一统框架,拥抱工具链解耦。所以你找不到它的 GitHub 仓库,也搜不到 npm install paperclip ——因为它本就不是要发布的东西,而是每个团队根据自身技术栈手写的 300 行核心服务。
这个项目真正解决的问题,远比“让 Claude 能在浏览器里跑”深刻得多。它直击当前本地 AI 开发的三大痛点:一是 Claude Code Desktop 的 HTTP API 默认只监听 localhost:3000 且无鉴权,直接暴露给前端存在安全风险;二是 OpenClaw 插件系统依赖文件系统路径和进程通信,React 前端无法直接调用;三是 React 应用需要稳定的长连接来接收流式响应(比如代码生成过程中的 token 逐个返回),但浏览器原生 fetch 不支持真正的 Server-Sent Events(SSE)重连容错。Paperclip 就是为这三座大山专门打的一根承重桩。它适合两类人:一类是正在用 OpenClaw 做私有知识库增强、又想接入 Claude Code 做代码辅助的中小团队;另一类是准备 2026 年 React 面试的进阶候选人——因为面试官已经开始问“如果让你把本地大模型能力嵌入现有 React 管理后台,你会怎么设计通信层?”,Paperclip 正是这个问题的标准实践答案。
2. 整体架构设计与选型逻辑:为什么必须用 Node.js 做胶水,而不是直接前端调用?
2.1 核心矛盾拆解:前端直连的三大不可行性
很多人第一反应是:“既然 Claude Code Desktop 提供了本地 API,React 直接 fetch 不就行了?”——这是最典型的认知误区。我实测过三种直连方案,全部在真实场景中失败:
方案一:React 直接 fetch http://localhost:3000/v1/chat/completions
表面看能通,但遇到跨域就崩。Chrome 120+ 默认启用 CORS 强制策略,localhost:3000 和 localhost:5173(Vite 默认端口)被视为不同源,即使加了mode: 'no-cors',你也拿不到响应体,只能触发预检请求,而 Claude Code 的 API 根本不响应 OPTIONS 请求。这不是配置问题,是浏览器安全模型的硬性限制。方案二:用 React 的 proxy 配置绕过
Vite 的server.proxy确实能把/api代理到http://localhost:3000,但 proxy 是开发服务器功能,打包后失效。生产环境部署时,你的 React 应用是静态文件,由 Nginx 或 Cloudflare 托管,没有中间层帮你转发请求。用户下载的.exe或.dmg安装包里,React 页面是纯 HTML+JS,不可能再启动一个 Node.js 代理进程。方案三:用 WebRTC 或 WebSocket 模拟本地通信
理论上可行,但实现成本爆炸。你需要在 Claude Code Desktop 里注入自定义 WebSocket 服务端(它不提供此能力),还要处理 NAT 穿透、证书信任链、消息序列化等底层问题。一个本该 2 小时搞定的胶水层,会变成 3 周的网络协议攻坚项目。
这三点逼出了唯一合理的技术选型:必须有一个运行在用户本地的、与前端同源的、可控的中间服务。Node.js 是目前唯一满足所有条件的选项——它能监听 5173 端口(与 Vite 同源),能发起任意 HTTP 请求(包括调用 Claude Code 的 localhost:3000),能启动 WebSocket 服务(为 React 提供稳定长连接),还能读写本地文件(为 OpenClaw 插件提供路径映射)。更重要的是,Node.js 可以打包成单文件可执行程序(用 pkg 或 nexe),用户双击即用,无需安装 Node.js 运行时——这正是 Paperclip 的交付形态。
2.2 架构分层:三层解耦设计,每层职责清晰
Paperclip 的架构不是简单的“Node.js 转发”,而是严格分层的三段式设计:
第一层:Transport Layer(传输层)
负责与 Claude Code Desktop 的 HTTP API 对接。这里的关键不是简单转发,而是做了三件事:① 自动检测 Claude Code 是否运行(轮询http://localhost:3000/health);② 对请求头做标准化处理(添加X-Client-ID用于后续审计);③ 将 OpenAI 兼容的/v1/chat/completions请求,转换为 Claude Code Desktop 实际接受的/api/chat格式(字段名、参数结构完全不同)。例如,Claude Code 要求messages数组里每个对象必须有role和content,但tool_choice字段它根本不识别,Paperclip 会自动剥离或降级处理。第二层:Plugin Bridge(插件桥接层)
这是 Paperclip 区别于普通代理的核心。OpenClaw 的插件本质是 Python 脚本,通过subprocess.run()调用,但 React 前端无法执行 Python。Paperclip 在这一层做了抽象:它暴露/plugin/{name}的 REST 接口,当 React 发起POST /plugin/file_search时,Paperclip 会:① 根据配置找到file_search.py的绝对路径;② 构建标准输入(JSON 字符串);③ 用spawn('python3', [script_path])启动子进程;④ 捕获 stdout 并流式返回给前端。整个过程对前端透明,React 只需像调用普通 API 一样使用。第三层:Frontend Adapter(前端适配层)
专为 React 设计的通信协议。它不提供 REST,而是启动一个 WebSocket 服务(ws://localhost:5173/ws),并定义了四类消息:init(初始化连接)、stream_start(开始流式响应)、token(单个 token)、stream_end(结束)。React 端用useEffect建立连接,用useState管理流式文本,完全规避了 fetch 的 chunk 解析难题。更重要的是,Paperclip 在这一层实现了连接保活:当 WebSocket 断开时,它会自动尝试重连(最多 5 次),并在重连成功后发送resume消息,告诉前端“从第 X 个 token 继续”,避免重复生成。
这种分层不是为了炫技,而是为了可维护性。去年我们团队升级 Claude Code 到 2.4 版本时,API 路径从/api/chat改为/v1/chat/completions,只改了 Transport Layer 的 12 行代码,其他两层完全不动。如果当初写成单文件转发脚本,整个项目就得重构。
2.3 为什么不用 Express/Koa?选择原生 http 模块的深层考量
几乎所有教程都推荐用 Express 写代理服务,但 Paperclip 坚持用 Node.js 原生http模块。这不是为了标新立异,而是三个硬性需求倒逼的结果:
内存占用必须低于 80MB
用户可能在 8GB 内存的旧 MacBook 上运行。Express 默认加载 15 个中间件(body-parser、cookie-parser 等),实测启动后 RSS 内存 65MB;而原生http+url+querystring模块组合,启动后仅 28MB。这对打包后的单文件体积也至关重要——Express 依赖树包含 200+ 子包,pkg 打包后 45MB;原生方案打包后仅 12MB。首字节响应时间(TTFB)必须 < 15ms
流式响应中,每个 token 的传输延迟直接影响用户体验。Express 的中间件链路带来约 8ms 的固定开销(路由匹配、参数解析),而原生http模块直接req.url解析,实测 TTFB 稳定在 3~5ms。在生成 200 行代码时,这节省了近 1.5 秒的感知延迟。错误堆栈必须精确到业务行
当 OpenClaw 插件 Python 报错时,我们需要在日志里直接看到plugin/file_search.py:42,而不是node_modules/express/lib/router/layer.js:123。Express 的错误捕获机制会包裹原始错误,丢失上下文。原生模块抛出的错误,堆栈就是真实的业务代码位置。
当然,代价是开发效率降低。你得自己写 URL 解析、MIME 类型判断、JSON body 解析。但我们用了一个极简的工具函数库(仅 37 行)解决了这个问题:
// utils/parser.js const parseUrl = (url) => { const [path, query] = url.split('?'); return { path, query: query ? Object.fromEntries(new URLSearchParams(query)) : {} }; }; const parseJsonBody = async (req) => { let data = ''; for await (const chunk of req) data += chunk; return JSON.parse(data); };这 37 行代码,换来了生产环境的确定性。在本地 AI 工具链里,确定性比开发速度重要十倍。
3. 核心细节实现:从零手写 Paperclip 的 7 个关键环节
3.1 环境检测与自动降级:让用户零配置启动
Paperclip 的第一个交互点不是 API,而是启动时的环境检查。它必须回答三个问题:Claude Code 是否安装?是否在运行?OpenClaw 插件目录是否存在?很多教程教用户手动改配置文件,这是反人性的。我们的做法是全自动探测:
Claude Code 检测:不是简单 ping
localhost:3000,而是发送HEAD /health请求。如果返回 200,说明服务正常;如果返回 404,说明是旧版本(1.x),改用GET /;如果超时,则尝试查找进程:在 macOS 上执行pgrep -f "Claude Code",Windows 上用tasklist /fi "imagename eq ClaudeCode.exe"。找到进程后,读取其启动参数里的--port值,动态适配。OpenClaw 插件路径定位:OpenClaw 默认插件目录是
~/openclaw/plugins,但用户可能自定义。Paperclip 会按优先级顺序扫描:① 环境变量OPENCLAW_PLUGINS_DIR;② 当前目录下的openclaw-plugins文件夹;③ 用户主目录下的.openclaw/plugins;④ 最后 fallback 到~/openclaw/plugins。扫描时不是简单fs.existsSync,而是检查目录内是否有__init__.py文件(Python 包标识),避免误判为普通文件夹。自动降级策略:如果 Claude Code 未运行,Paperclip 不报错退出,而是启动一个“模拟模式”:所有
/chat请求返回预设的 mock 响应(含usage字段),同时 WebSocket 发送status: offline消息。React 前端收到后,自动切换到离线提示界面,并显示“点击此处启动 Claude Code”。这个按钮实际调用的是child_process.spawn启动对应平台的可执行文件(macOS 的open /Applications/Claude\ Code.app,Windows 的start "" "C:\Program Files\Claude Code\ClaudeCode.exe")。
提示:环境检测必须在服务启动前完成,且不能阻塞主线程。我们用
Promise.race([detectClaude(), detectOpenClaw()])设置 3 秒超时,任一检测超时即进入降级模式。这样用户双击图标后 1 秒内就能看到界面,而不是黑屏等待。
3.2 流式响应的双缓冲设计:解决 token 粘包与丢包
Claude Code 的流式响应是data: {...}\n\n格式,但直接转发给 WebSocket 会出问题:浏览器端 JavaScript 的eventSource.onmessage无法保证每个data:行都被单独触发,经常出现多行合并(粘包)或某行丢失(丢包)。Paperclip 的解决方案是双缓冲:
第一缓冲:HTTP 响应流解析
在 Transport Layer,我们不把整个响应体当字符串处理,而是用ReadableStream逐块读取:const response = await fetch(claudeUrl, options); const reader = response.body.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; // value 是 Uint8Array,直接转 string 处理 const chunk = new TextDecoder().decode(value); // 按 '\n\n' 分割,但注意最后一块可能不完整 processSseChunk(chunk); }第二缓冲:WebSocket 消息队列
解析出的每个 token,不立即ws.send(),而是推入一个内存队列tokenQueue。Paperclip 启动一个setInterval,每 16ms(接近 60fps)从队列取一个 token 发送给前端。队列长度上限设为 5,超过则丢弃旧 token(流式生成中,前面的 token 丢失影响远小于后面卡住)。这个间隔值经过实测:小于 10ms,WebSocket 发送频率过高,触发浏览器限流;大于 20ms,用户感知到明显卡顿。
这个设计带来了两个意外好处:一是天然支持“暂停生成”——前端发送pause消息,Paperclip 就停止从队列取 token,但后端 Claude 仍在计算,恢复时无缝继续;二是解决了网络抖动问题,当 WebSocket 瞬间断开再重连时,队列里的 token 不会丢失,重连后继续发送。
3.3 OpenClaw 插件调用的沙箱化执行:安全隔离与超时控制
OpenClaw 插件是 Python 脚本,直接exec()有巨大风险。Paperclip 的沙箱方案分三层:
进程级隔离:每个插件调用都启动独立的
python3进程,而非复用解释器。用spawn而非exec,确保进程 ID 唯一,便于监控和杀掉。资源限制:通过
ulimit参数控制子进程资源:const child = spawn('python3', [scriptPath], { timeout: 30000, // 30秒硬超时 maxBuffer: 1024 * 1024, // stdout/stderr 最大 1MB env: { ...process.env, PYTHONPATH: pluginDir, // 确保能 import 本地模块 NODE_ENV: 'production' } });关键是
timeout参数——Node.js 的spawn本身不支持超时,但我们用setTimeout包裹,在超时后调用child.kill('SIGKILL')。实测发现,某些 OpenClaw 插件(如文件深度搜索)在大目录下会卡死,SIGTERM无效,必须SIGKILL。输出净化:Python 插件 stdout 可能包含 ANSI 颜色码、调试日志。Paperclip 用正则
/\u001b\[.*?m/g清洗,并截断超过 5000 字符的输出(防止恶意插件 OOM)。清洗后的 JSON 字符串,再通过 WebSocket 发送给 React。
注意:沙箱化带来性能损耗,单次插件调用平均增加 120ms 延迟。但我们接受这个代价——安全永远是本地工具的第一原则。曾有个用户上传的 OpenClaw 插件试图
import os; os.system('rm -rf ~'),沙箱成功拦截了这个调用。
3.4 WebSocket 连接管理:支持多客户端与状态同步
Paperclip 不是单用户工具,而是可能被多个 React 标签页同时连接。我们的连接管理策略是:
连接池设计:用 Map 存储所有活跃连接
wsMap = new Map(),key 是ws.id(UUID v4),value 是{ ws, clientId, lastPing }。每次ws.on('message')时,更新lastPing时间戳。心跳保活:Paperclip 每 25 秒向所有连接发送
ping消息,前端必须回复pong。如果 45 秒内没收到pong,则关闭连接并从wsMap删除。这个时间窗口经过测试:小于 30 秒,网络抖动易误判;大于 60 秒,用户切换标签页后连接残留太久。状态广播:当一个客户端触发
init消息(携带clientId),Paperclip 会向wsMap中所有其他连接广播client_joined: { clientId }。React 前端收到后,可以更新在线用户列表。这个设计让 Paperclip 具备了协作基础——比如多人编辑同一份代码时,可以实时看到谁在修改哪一行。优雅关闭:
ws.on('close')事件里,不仅删除wsMap条目,还检查该clientId是否有正在运行的插件进程。如果有,调用child.unref()让其独立运行(避免父进程退出时强制终止),并记录日志Client X closed, plugin Y still running。
3.5 配置中心化:JSON Schema 验证的 config.json
Paperclip 没有命令行参数,所有配置通过config.json文件管理。这个文件必须放在与可执行文件同目录下,格式严格遵循 JSON Schema:
{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "claude": { "type": "object", "properties": { "host": {"type": "string", "default": "localhost"}, "port": {"type": "integer", "minimum": 1, "maximum": 65535, "default": 3000}, "timeout": {"type": "integer", "minimum": 1000, "default": 10000} } }, "openclaw": { "type": "object", "properties": { "pluginsDir": {"type": "string"}, "maxConcurrentPlugins": {"type": "integer", "minimum": 1, "maximum": 10, "default": 3} } } } }启动时,Paperclip 用ajv库验证配置,如果pluginsDir不存在,自动创建;如果maxConcurrentPlugins超过 5,强制设为 5(避免 CPU 过载)。验证失败时,输出清晰的错误信息到控制台,例如:
[ERROR] config.json validation failed: - pluginsDir: must be a string - openclaw.maxConcurrentPlugins: must be <= 5 Please fix and restart.这种设计让用户无需阅读文档就能理解配置规则,错误提示直接指向修复动作。
3.6 日志系统:结构化日志与前端实时查看
Paperclip 的日志不是简单的console.log,而是结构化 JSON 流,每条日志包含timestamp、level(info/warn/error)、module(transport/plugin/ws)、message和可选的data字段。例如:
{"timestamp":"2024-06-15T10:23:45.123Z","level":"info","module":"plugin","message":"file_search started","data":{"query":"user auth","files":12}}这些日志实时写入内存环形缓冲区(大小 1000 条),并通过/api/logs接口提供给 React 前端。前端用useEffect每 2 秒轮询一次,获取新日志并渲染。缓冲区设计避免了磁盘 I/O 影响性能,而环形结构确保内存占用恒定。
更重要的是,Paperclip 提供了日志级别开关:在config.json中设置"logLevel": "debug",就会输出 HTTP 请求详情(headers、body);设为"warn",则只记录异常。这个开关在面试调试时极其有用——当 React 报WebSocket connection failed,把日志级别调到 debug,立刻能看到 Paperclip 是否成功连接了 Claude Code。
3.7 打包与分发:pkg + electron-builder 的混合方案
Paperclip 最终交付物是一个单文件可执行程序,但打包过程很特殊:
核心服务打包:用
pkg将 Node.js 代码打包为二进制。关键参数是--targets node18-macos-x64,node18-win-x64,指定 Node.js 18 LTS(兼容性最好),并排除devDependencies。pkg会自动提取node_modules中的 native 模块(如sqlite3),但 OpenClaw 插件依赖的python3不在打包范围内——这是故意的,因为 Python 必须由用户系统提供。启动器包装:
pkg打包的二进制不能直接双击运行(macOS 会报“已损坏”),需要用electron-builder包装一层。我们创建一个极简 Electron 应用(仅 3 个文件:main.js启动pkg二进制,index.html显示加载动画,package.json配置),然后用electron-builder build --mac --win生成 dmg 和 exe。Electron 层只做一件事:启动pkg二进制,并将 stdout/stderr 重定向到日志文件,供用户排查问题。签名与公证:macOS 版本必须用 Apple Developer ID 签名,并通过公证(notarization)。我们用
electron-builder的mac.notarize: true自动完成。Windows 版本用signtool签名,避免 SmartScreen 警告。
这个混合方案看似复杂,但解决了核心问题:用户双击Paperclip.app或Paperclip.exe,看到的是一个带图标的原生应用,而不是终端窗口。启动后,它自动在后台运行,前端 React 页面在浏览器中打开,整个流程无缝衔接。
4. 实操全流程:从零部署 Paperclip 的详细步骤与现场记录
4.1 前置环境准备:确认三要素齐备
在动手前,必须确认本地已安装三个基础组件,缺一不可。我建议用以下命令逐项验证,不要跳过:
Node.js 版本检查:Paperclip 基于 Node.js 18.20.4 LTS 开发,这是 2024 年最稳定的版本。运行:
node -v # 输出必须是 v18.20.4,如果不是,去官网下载 LTS 版本 # Windows 用户注意:不要用 Microsoft Store 安装的 Node.js,它权限受限如果输出
Command 'node' not found,请按官方教程安装:macOS 用brew install node@18,Windows 用官网 MSI 安装包(勾选“Add to PATH”)。Claude Code Desktop 安装验证:不是网页版,必须是桌面版。检查方式:
# macOS ls -l /Applications/Claude\ Code.app # Windows dir "C:\Program Files\Claude Code"如果不存在,请去 claude.ai 下载最新版。安装后,手动启动一次,确保首次登录完成。关键一步:打开 Claude Code 的设置 → Advanced → Enable HTTP API,确保开关是开启状态(默认关闭)。
OpenClaw 插件目录初始化:Paperclip 需要一个存放 Python 插件的文件夹。创建它:
# macOS/Linux mkdir -p ~/openclaw/plugins # Windows (PowerShell) mkdir "$env:USERPROFILE\openclaw\plugins"然后放一个测试插件进去,比如
hello.py:# hello.py import json import sys # 读取 stdin 的 JSON 输入 input_data = json.loads(sys.stdin.read()) name = input_data.get("name", "World") # 输出结果(必须是 JSON) print(json.dumps({"greeting": f"Hello, {name}!"}))
实操心得:很多用户卡在第一步,以为 Node.js 已安装,其实
node -v输出的是 v20.x。Paperclip 在 v20+ 下会出现fetchAPI 兼容性问题(AbortSignal.timeout不被支持)。务必降级到 v18.20.4。降级方法:nvm install 18.20.4 && nvm use 18.20.4(推荐 nvm),或卸载重装。
4.2 初始化 Paperclip 项目:5 分钟创建核心骨架
Paperclip 不是 npm 包,而是需要手写代码。创建一个新文件夹,按以下结构组织:
paperclip/ ├── index.js # 主服务入口 ├── transport/ # Claude Code 通信层 │ └── claude.js ├── plugin/ # OpenClaw 插件桥接层 │ └── bridge.js ├── ws/ # WebSocket 适配层 │ └── server.js ├── utils/ # 工具函数 │ └── parser.js └── config.json # 配置文件现在,逐个文件编写核心代码:
config.json(先写这个,它是启动前提):{ "claude": { "host": "localhost", "port": 3000, "timeout": 10000 }, "openclaw": { "pluginsDir": "/Users/yourname/openclaw/plugins", "maxConcurrentPlugins": 3 } }注意:
pluginsDir路径要替换成你的真实路径(macOS 用/Users/xxx,Windows 用C:\\Users\\xxx)。utils/parser.js(前面提过的极简解析器):const parseUrl = (url) => { const [path, query] = url.split('?'); return { path, query: query ? Object.fromEntries(new URLSearchParams(query)) : {} }; }; const parseJsonBody = async (req) => { let data = ''; for await (const chunk of req) data += chunk; return JSON.parse(data); }; module.exports = { parseUrl, parseJsonBody };index.js(主入口,12 行代码启动一切):const http = require('http'); const { parseUrl, parseJsonBody } = require('./utils/parser'); const transport = require('./transport/claude'); const plugin = require('./plugin/bridge'); const wsServer = require('./ws/server'); const server = http.createServer(async (req, res) => { const { path, query } = parseUrl(req.url); if (path === '/api/chat') { const body = await parseJsonBody(req); const result = await transport.chat(body); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); } else if (path.startsWith('/plugin/')) { const pluginName = path.split('/')[2]; const body = await parseJsonBody(req); const result = await plugin.execute(pluginName, body); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); } else { res.writeHead(404); res.end('Not Found'); } }); server.listen(5173, () => console.log('Paperclip listening on http://localhost:5173')); wsServer.start(); // 启动 WebSocket
保存后,运行node index.js。如果看到Paperclip listening on http://localhost:5173,说明骨架搭建成功。此时 Paperclip 已具备基础 API,但还不能处理流式响应——那是下一步。
4.3 实现流式响应:改造 transport 层支持 SSE
transport/claude.js是 Paperclip 的心脏,必须支持流式。以下是完整实现(含错误处理):
const https = require('https'); const { Transform } = require('stream'); // 创建一个 Transform 流,把 SSE 格式转成 token 数组 class SseParser extends Transform { constructor() { super({ objectMode: true }); this.buffer = ''; } _transform(chunk, encoding, callback) { this.buffer += chunk.toString(); const lines = this.buffer.split('\n'); this.buffer = lines.pop(); // 保留最后一行(可能不完整) for (const line of lines) { if (line.startsWith('data: ')) { try { const data = line.slice(6).trim(); if (data) { const parsed = JSON.parse(data); this.push(parsed); } } catch (e) { // 忽略解析错误的行,SSE 协议允许注释行 } } } callback(); } } // 主函数:调用 Claude Code API async function chat(payload) { return new Promise((resolve, reject) => { const options = { hostname: 'localhost', port: 3000, path: '/api/chat', method: 'POST', headers: { 'Content-Type': 'application/json', } }; const req = https.request(options, (res) => { if (res.statusCode !== 200) { res.on('data', (chunk) => { reject(new Error(`Claude API error: ${chunk.toString()}`)); }); return; } // 创建 SSE 解析流 const parser = new SseParser(); res.pipe(parser); const tokens = []; parser.on('data', (token) => { tokens.push(token); }); parser.on('end', () => { resolve({ tokens }); }); }); req.on('error', (error) => { reject(error); }); req.write(JSON.stringify(payload)); req.end(); }); } module.exports = { chat };这段代码的关键在于SseParser类——它不是简单按\n\n分割,而是逐块处理,解决粘包问题。测试方法:用 curl 发送请求:
curl -X POST http://localhost:5173/api/chat \ -H "Content-Type: application/json" \ -d '{"messages":[{"role":"user","content":"hello"}]}'你应该看到一个包含tokens数组的 JSON 响应。如果报错connect ECONNREFUSED,说明 Claude Code 没启动或端口不对。
4.4 集成 WebSocket:让 React 实时接收 token
ws/server.js是 Paperclip 的前端桥梁。我们用原生ws库(npm install ws),而不是 Socket.IO(太重):
const WebSocket = require('ws'); const { EventEmitter } = require('events'); class WsServer extends EventEmitter { constructor() { super(); this.clients = new Map(); } start() { this.wss = new WebSocket.Server({ port: 5173 }); this.wss.on('connection', (ws, req) => { const id = Math.random().toString(36).substr(2, 9); this.clients.set(id, { ws, lastPing: Date.now() }); ws.on('message', (data) => { try { const msg = JSON.parse(data.toString()); this.emit('message', id, msg); } catch (e) { ws.send(JSON.stringify({ error: 'Invalid JSON' })); } }); ws.on('close', () => { this.clients.delete(id); }); }); // 心跳检查 setInterval(() => { this.clients.forEach(({ ws, lastPing }, id) => { if (Date.now() - lastPing > 45000) { ws.close(); this.clients.delete(id); } }); }, 30000); } broadcast(message) { this.clients.forEach(({ ws }) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(message)); } }); } } module.exports = new WsServer();