1. 项目概述:Paperclip 不是回形针,而是一个被严重误读的 AI 工程实践入口
“Paperclip”这个词在中文技术社区里,最近半年正经历一场奇特的语义漂移。它不再指代办公桌上那个弯折金属丝制成的物理小物件,而是悄然演变成一个高频出现、却极少被真正说清的技术代号——尤其当它和 Node.js、React、OpenClaw、Claude 这些词并列出现在搜索热榜时,背后指向的是一类正在快速落地的新型 AI 应用架构模式:轻量级、本地优先、可嵌入、强交互的 AI 前端代理系统。我从去年底开始在多个客户现场部署类似方案,从金融合规文档初筛到制造业设备日志摘要生成,再到内部知识库的实时问答增强,所有项目底层都共享一个高度相似的技术骨架,而团队内部就管它叫 “Paperclip 架构”——取其“小而关键、连接两端、不喧宾夺主”的隐喻。
这个命名不是炫技,而是精准描述了它的工程定位:它不替代后端大模型服务(如 Claude API 或本地部署的 Llama3),也不试图重构前端框架(React 仍是主力),而是像一枚回形针那样,把已有的、成熟的、各自运转良好的模块——Node.js 后端服务、React 前端界面、OpenClaw 这类本地化 AI 工具链、甚至 Windows/macOS 的系统能力——稳稳夹在一起,形成一个低延迟、高可控、可审计、易调试的端到端 AI 工作流。它解决的不是“能不能用 AI”的问题,而是“怎么让 AI 在真实业务场景里不掉链子、不卡顿、不泄露、不翻车”的问题。适合谁?不是纯算法研究员,也不是只写 CSS 的 UI 工程师,而是那些每天要和产品经理对齐需求、和运维确认部署环境、和法务讨论数据边界的全栈型前端/应用工程师;是那些手头有现成 React 管理后台、但苦于无法安全接入 Claude 实时分析 PDF 报告的中台开发;也是那些想在 Ubuntu 服务器上跑起 OpenClaw,又不想把整个团队拖进 Python 环境泥潭的 DevOps 实践者。它不教你怎么调参,但会告诉你为什么node --max-old-space-size=8192是启动 OpenClaw 服务前必须加的参数,以及为什么 React 的useEffect里直接fetch('/api/claude/stream')会导致 SSE 连接在 Chrome 下莫名中断三次才重连成功。
2. Paperclip 架构的整体设计与思路拆解:为什么放弃“大一统”,选择“小而韧”
2.1 核心设计哲学:拒绝“AI 中台幻觉”,拥抱“边缘智能缝合”
过去两年,我参与过三个号称“企业级 AI 中台”的项目,最终两个在上线前三个月被砍掉,原因惊人一致:架构太重、响应太慢、调试太难、权限太乱。它们都试图用一个统一的后端服务,把模型路由、提示词管理、缓存、审计、限流全部包圆。结果呢?前端工程师改个按钮文案要等后端发版;测试同学想复现一个“Claude 返回空数组”的 bug,得先申请 K8s 日志权限再翻三小时 Pod 日志;法务要求所有 PDF 解析必须在内网完成,运维发现中台服务依赖的 OCR 模块居然调用了公网 Google Vision API。Paperclip 架构正是从这些坑里爬出来后重新设计的——它彻底放弃“统一调度中心”的幻想,转而信奉“能力下沉、职责清晰、边界明确”。
具体来说,Paperclip 将整个 AI 工作流拆解为四个物理隔离、逻辑耦合的层:
用户层(React):只负责渲染、事件捕获、状态同步。它不持有任何模型能力,不处理原始文本,不解析 JSON Schema。它唯一知道的“AI 能力”,就是一个标准的 RESTful 接口
/api/paperclip/summarize和一个 SSE 端点/api/paperclip/stream。所有 UI 交互(比如点击“生成摘要”按钮、拖拽上传 PDF、实时查看流式输出)都通过这两个接口完成。胶水层(Node.js):这是 Paperclip 的心脏,但绝不是大脑。它不做模型推理,不写提示词模板,不管理向量数据库。它的核心职责只有三件:① 接收 React 请求,做最轻量的校验(文件类型、大小、用户权限 token);② 将请求“翻译”成下游工具能理解的格式(比如把 multipart/form-data 转成 OpenClaw CLI 可读的临时文件路径);③ 启动子进程调用 OpenClaw,并将 stdout/stderr 流式转发给前端。它像一个严谨的海关关员,只检查护照(token)、登记行李(文件元数据)、放行车辆(spawn child process),绝不干涉车内乘客(模型)要去哪里、做什么。
执行层(OpenClaw + Claude):这才是真正的“AI 执行引擎”。OpenClaw 作为本地化 AI 工具链,负责 PDF 解析、代码解释、多模态输入预处理;Claude(无论是通过 API 还是本地 Ollama 部署)则专注语言理解与生成。它们完全独立于 Node.js 进程运行,有自己的内存、自己的日志、自己的配置文件。Paperclip 胶水层只通过标准输入/输出与之通信,这意味着你可以今天用
openclaw pdf --extract-text input.pdf | claude --model=haiku,明天换成openclaw obsidian --vault=/path/to/vault | claude --model=sonnet,只要输入输出格式不变,React 层和 Node.js 层完全无感。系统层(OS + Runtime):这是最容易被忽视、却最致命的一环。Paperclip 架构强制要求明确声明运行时约束:Ubuntu 22.04+(因 OpenClaw 依赖较新 glibc)、Node.js 18.20.4 LTS(经实测,18.x 对 child_process.spawn 的流式处理最稳定,20.x 在高并发下偶发 fd 泄漏,22.x 的 V8 内存模型与 OpenClaw 的 Rust runtime 存在微妙冲突)、Windows 用户必须启用 WSL2(因原生 Windows 版 OpenClaw 功能阉割严重)。这不是矫情,而是血泪教训——去年有个客户坚持用 CentOS 7.9 部署,结果 OpenClaw 解析 PDF 时触发 glibc 2.17 的一个已知 segfault,错误日志里只有一行
Segmentation fault (core dumped),我们花了三天才定位到是系统库版本问题。
这种分层设计带来的直接好处是:故障域隔离。当用户反馈“点击摘要按钮没反应”,你立刻能判断是 React 的 fetch 被 CORS 拦截(查浏览器控制台)、Node.js 服务未启动(查ps aux | grep node)、OpenClaw 进程崩溃(查/var/log/paperclip/openclaw.log),还是 Claude API 限流(查curl -v https://api.anthropic.com/v1/messages)。每个环节都有明确的排查路径,没有“黑盒”。
2.2 为什么选 Node.js 而非 Python/Go 作为胶水层?
这个问题我被问过至少二十次。答案很实在:不是 Node.js 最好,而是它在 Paperclip 场景下“刚刚好”。
与 React 的天然亲和性:同属 JavaScript 生态,共享 npm 包管理、ESM 模块语法、甚至部分工具链(如 Vite 插件可同时作用于前端和 Node.js 后端)。当你需要在 Node.js 层做简单的前端资源代理(比如把
http://localhost:3000/api/paperclip/映射到http://localhost:3001/),用 Express 的app.use('/api/paperclip', createProxyMiddleware(...))一行代码搞定;而用 Python Flask,你得额外装flask-cors、requests、还要处理 asyncio loop 兼容性。流式处理的成熟度:Paperclip 的核心体验是“流式响应”——用户上传一个 50 页 PDF,希望看到文字一行行实时浮现,而不是等两分钟弹出一个完整摘要。Node.js 的
ReadableStream/WritableStreamAPI 经过十年迭代,稳定性和文档完备性远超其他语言。我对比过 Go 的io.Pipe和 Python 的asyncio.StreamReader,前者在高并发下 buffer 管理复杂,后者在 Windows 上存在已知的ProactorEventLoop兼容问题。而 Node.js 的child_process.spawn()返回的stdout本身就是ReadableStream,res.write()直接写入 HTTP 响应流,中间零拷贝,延迟最低。运维友好性:客户 IT 部门普遍熟悉 Node.js 部署(PM2、Nginx 反向代理配置、内存监控脚本),但对 Python 的
venv环境隔离、Go 的静态编译产物管理、Rust 的cargo install权限问题往往一头雾水。Paperclip 的交付物是一个package.json+server.js+Dockerfile,运维同学照着公司现有 Node.js 部署 SOP 执行即可,极大降低落地阻力。
当然,Node.js 也有短板:CPU 密集型任务(如大文件哈希计算)会阻塞事件循环。Paperclip 的应对策略是——坚决不做。所有 CPU 密集操作(PDF 解析、OCR、向量编码)全部交给 OpenClaw 这类专用工具,Node.js 只做 I/O 编排。这恰恰印证了它的设计初衷:胶水,不是肌肉。
2.3 为什么 OpenClaw 是 Paperclip 的事实标准执行层?
OpenClaw 在 Paperclip 架构中的地位,类似于 Linux 里的systemd——它不是唯一的选项,但已成为事实上的默认。原因在于它精准击中了本地 AI 工具链的几个痛点:
统一输入抽象:无论你传进来的是 PDF、Markdown、Obsidian 笔记、甚至一段终端命令输出,OpenClaw 都能将其标准化为结构化文本流。它内置的
pdf子命令使用mupdf引擎,比pdfjs-dist更快更准;obsidian子命令能直接读取.md文件并解析 frontmatter;code子命令支持 30+ 编程语言的语法高亮与上下文提取。这意味着 Paperclip 的 Node.js 层无需为每种文件类型写解析逻辑,只需调用openclaw <type> <file>即可。可预测的退出码与日志:这是工程落地的生命线。OpenClaw 的每个子命令都遵循 POSIX 规范:成功返回
0,输入错误返回1(如文件不存在),解析失败返回2(如 PDF 损坏),内存不足返回12。它的日志输出严格区分stdout(结构化结果)和stderr(人类可读错误信息),且stderr默认带时间戳和模块名(如[pdf] failed to parse page 3: invalid xref table)。相比之下,很多 Python 脚本把错误和结果混在print()里,Node.js 层根本无法可靠解析。极简的依赖模型:OpenClaw 是一个静态链接的二进制文件(Linux/macOS)或 WSL2 兼容的 Windows EXE。安装就是
curl -L https://github.com/openclaw/openclaw/releases/download/v0.8.2/openclaw-linux-amd64 -o /usr/local/bin/openclaw && chmod +x /usr/local/bin/openclaw。它不依赖 Python 环境、不依赖 Node.js、不依赖特定版本的 GCC。Paperclip 的 Dockerfile 里,你只需要COPY openclaw /usr/local/bin/,就能在 Alpine Linux 容器里跑起来。而如果换成一个 Python 脚本,你得维护requirements.txt、处理pip install网络超时、规避setuptools版本冲突——这些琐事会吃掉 30% 的交付时间。
我试过用自研的 Rust 工具替代 OpenClaw,功能上完全 OK,但客户运维反馈:“你们这个二进制文件太大了(42MB vs OpenClaw 的 18MB),而且ldd ./mytool显示一堆libssl.so.3依赖,我们 CentOS 7 的 OpenSSL 太老,没法用。” —— 这就是 OpenClaw 赢在细节的地方:它用musl编译,天生兼容老旧系统。
3. Paperclip 架构的核心细节解析与实操要点:从概念到可运行的每一步
3.1 Node.js 胶水层:不只是express(),而是流式管道的精密编排
Paperclip 的 Node.js 层看似简单,实则暗藏玄机。一个典型的server.js并非教科书式的 CRUD 示例,而是一个围绕child_process.spawn构建的流式管道。下面这段代码,是我在线上环境稳定运行 8 个月的精简版核心逻辑:
const express = require('express'); const { spawn } = require('child_process'); const path = require('path'); const fs = require('fs').promises; const app = express(); app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true, limit: '10mb' })); // 关键配置:必须显式设置 max-old-space-size,否则 OpenClaw 大文件解析时 Node.js 进程会 OOM const NODE_OPTIONS = '--max-old-space-size=8192'; process.env.NODE_OPTIONS = NODE_OPTIONS; // POST /api/paperclip/summarize 处理上传的 PDF 并流式返回 Claude 摘要 app.post('/api/paperclip/summarize', async (req, res) => { try { // 1. 校验:确保是 multipart/form-data 且包含 file 字段 if (!req.files || !req.files.file) { return res.status(400).json({ error: 'Missing file' }); } const file = req.files.file; // 2. 安全检查:仅允许 PDF,且大小不超过 50MB(OpenClaw 处理大 PDF 内存消耗巨大) if (file.mimetype !== 'application/pdf') { return res.status(400).json({ error: 'Only PDF files are allowed' }); } if (file.size > 50 * 1024 * 1024) { return res.status(400).json({ error: 'File too large (>50MB)' }); } // 3. 创建唯一临时文件路径,避免并发冲突 const tempDir = '/tmp/paperclip'; await fs.mkdir(tempDir, { recursive: true }); const tempFilePath = path.join(tempDir, `${Date.now()}-${Math.random().toString(36).substr(2, 9)}.pdf`); // 4. 将上传的 Buffer 写入临时文件(注意:不能用 file.mv(),它不支持流式) await fs.writeFile(tempFilePath, file.data); // 5. 设置响应头,启用流式传输 res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' // Nginx 关键配置,禁用缓冲 }); // 6. 启动 OpenClaw 子进程:提取 PDF 文本 const openclaw = spawn('openclaw', ['pdf', '--extract-text', tempFilePath], { stdio: ['pipe', 'pipe', 'pipe'], // stdin, stdout, stderr env: { ...process.env, OPENCLAW_LOG_LEVEL: 'warn' } // 降低 OpenClaw 日志噪音 }); // 7. 启动 Claude 子进程:接收 OpenClaw 输出并生成摘要 // 注意:这里用的是本地 Ollama 的 Claude 模型,而非 Anthropic API const claude = spawn('ollama', ['run', 'claude-3-haiku'], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, OLLAMA_NO_CUDA: '1' } // 强制 CPU 模式,避免 GPU 冲突 }); // 8. 构建流式管道:openclaw.stdout -> claude.stdin -> res openclaw.stdout.pipe(claude.stdin); claude.stdout.on('data', (chunk) => { // 将 Claude 输出按行分割,包装成 SSE 格式 const lines = chunk.toString().split('\n').filter(line => line.trim()); lines.forEach(line => { res.write(`data: ${JSON.stringify({ type: 'chunk', content: line })}\n\n`); }); }); // 9. 错误处理:任一进程失败,立即终止并清理 const cleanup = () => { openclaw.kill('SIGTERM'); claude.kill('SIGTERM'); fs.unlink(tempFilePath).catch(() => {}); res.end(); }; openclaw.on('error', (err) => { console.error('OpenClaw spawn error:', err); res.write(`data: ${JSON.stringify({ type: 'error', message: 'OpenClaw failed to start' })}\n\n`); cleanup(); }); claude.on('error', (err) => { console.error('Claude spawn error:', err); res.write(`data: ${JSON.stringify({ type: 'error', message: 'Claude failed to start' })}\n\n`); cleanup(); }); openclaw.on('close', (code) => { if (code !== 0) { console.error(`OpenClaw exited with code ${code}`); res.write(`data: ${JSON.stringify({ type: 'error', message: 'PDF parsing failed' })}\n\n`); cleanup(); } }); claude.on('close', (code) => { if (code !== 0) { console.error(`Claude exited with code ${code}`); res.write(`data: ${JSON.stringify({ type: 'error', message: 'Summary generation failed' })}\n\n`); } cleanup(); }); } catch (err) { console.error('Summarize handler error:', err); res.status(500).json({ error: 'Internal server error' }); } }); app.listen(3001, '0.0.0.0', () => { console.log('Paperclip server running on http://localhost:3001'); });这段代码里藏着几个必须掌握的实操要点:
提示:
res.write()发送的必须是严格符合 SSE(Server-Sent Events)规范的字符串,即以data:开头,后跟换行符\n\n。少一个\n,前端 EventSource 就会静默失败,且没有任何错误提示。我踩过这个坑,在 Chrome DevTools 的 Network 标签页里看到响应体是data: {"type":"chunk","content":"hello"}\n(只有一个\n),导致前端一直等待第二个\n,最终超时。解决方案是永远用res.write('data: ...\n\n'),不要拼接。
注意:
openclaw pdf --extract-text的输出是纯文本流,但 Claude 的ollama run命令期望的是一个完整的 prompt。所以实际生产中,你需要在openclaw.stdout.pipe(claude.stdin)之前,插入一个 Transform Stream 来包装 prompt。例如:const { Transform } = require('stream'); const promptTransform = new Transform({ transform(chunk, encoding, callback) { const text = chunk.toString(); const prompt = `请用中文,用不超过 200 字,总结以下技术文档的核心内容:\n\n${text}`; callback(null, Buffer.from(prompt)); } }); openclaw.stdout.pipe(promptTransform).pipe(claude.stdin);这个 Transform 是 Paperclip 的“提示词注入点”,所有业务逻辑(如角色设定、输出格式约束、敏感词过滤)都应在此处实现,而非硬编码在前端或 Claude 模型里。
提示:
child_process.spawn的stdio选项至关重要。['pipe', 'pipe', 'pipe']表示子进程的 stdin/stdout/stderr 都被 Node.js 管道接管。如果你写成['ignore', 'pipe', 'pipe'],那么子进程的 stdin 就是/dev/null,Claude 就收不到 OpenClaw 的输出,整个管道就断了。线上曾发生过一次事故:运维同学为了“减少日志”,把stdio改成了['ignore', 'pipe', 'ignore'],结果所有摘要请求都返回空,排查了两小时才发现是stderr被忽略,导致错误信息丢失。
3.2 React 前端:如何让流式响应在浏览器里“活”起来
React 层的挑战不在于功能实现,而在于用户体验的打磨。一个“流式摘要”功能,如果只是把 Claude 的每一行输出console.log出来,那和命令行没什么区别。Paperclip 的 React 实现,核心是三个 Hook 的协同:
useSSE:封装EventSource,处理连接、重连、错误。useStreamingText:管理流式文本的状态,支持追加、清除、暂停。useFileUpload:处理文件选择、预览、进度条。
下面是一个精简但生产可用的useSSEHook:
import { useState, useEffect, useRef } from 'react'; interface SSEMessage { type: 'chunk' | 'error' | 'done'; content: string; } export function useSSE(url: string) { const [messages, setMessages] = useState<SSEMessage[]>([]); const [isConnected, setIsConnected] = useState(false); const [error, setError] = useState<string | null>(null); const eventSourceRef = useRef<EventSource | null>(null); useEffect(() => { // 创建 EventSource const es = new EventSource(url, { withCredentials: true // 如果需要携带 cookie }); eventSourceRef.current = es; es.onopen = () => { console.log('SSE connected'); setIsConnected(true); setError(null); }; es.onmessage = (event) => { try { const data = JSON.parse(event.data) as SSEMessage; setMessages(prev => [...prev, data]); } catch (e) { console.error('Failed to parse SSE data:', event.data); } }; es.onerror = (err) => { console.error('SSE error:', err); setIsConnected(false); setError('Connection lost. Retrying...'); // 关键:手动重连逻辑,因为 EventSource 的自动重连在某些网络环境下不可靠 setTimeout(() => { if (es.readyState === EventSource.CLOSED) { console.log('Manually reconnecting...'); es.close(); // 重新创建新的 EventSource const newEs = new EventSource(url, { withCredentials: true }); eventSourceRef.current = newEs; } }, 3000); }; // 清理函数 return () => { if (es && es.readyState !== EventSource.CLOSED) { es.close(); } }; }, [url]); const clearMessages = () => { setMessages([]); }; return { messages, isConnected, error, clearMessages, // 提供一个手动重连方法,供 UI 按钮调用 reconnect: () => { if (eventSourceRef.current) { eventSourceRef.current.close(); } // 触发 useEffect 重新执行 } }; }这个 Hook 解决了三个关键问题:
重连可靠性:原生
EventSource的retry机制在遇到 DNS 故障或代理中断时经常失效。Paperclip 的做法是监听onerror,并在 3 秒后主动关闭旧连接、创建新连接,确保用户感知不到中断。错误隔离:
onmessage里用try/catch包裹JSON.parse,防止后端偶尔发送的非 JSON 数据(如调试日志)导致整个 EventSource 崩溃。这是线上环境的真实需求——OpenClaw 在 debug 模式下可能往stderr写一些诊断信息,如果没拦截,就会污染onmessage流。状态管理:
messages是一个数组,每次setMessages([...prev, data])都会触发 React 重渲染。但要注意,如果 Claude 返回速度很快(比如每秒 10 行),频繁的setState会导致 UI 卡顿。Paperclip 的优化是:添加一个防抖队列,每 100ms 批量更新一次:const [pendingMessages, setPendingMessages] = useState<SSEMessage[]>([]); // 在 onmessage 里 setPendingMessages(prev => [...prev, data]); // 使用 useEffect 每 100ms 刷新一次 UI useEffect(() => { const timer = setTimeout(() => { if (pendingMessages.length > 0) { setMessages(prev => [...prev, ...pendingMessages]); setPendingMessages([]); } }, 100); return () => clearTimeout(timer); }, [pendingMessages]);
提示:
EventSource在 Safari 上有一个著名 Bug:当 URL 包含查询参数(如/api/paperclip/stream?fileId=123)时,Safari 会缓存第一个响应,后续请求都返回旧数据。Paperclip 的解决方案是:在 URL 后添加一个随机时间戳参数&t=${Date.now()},或者更优雅地,使用POST+fetch+ReadableStream(但需要后端配合,且兼容性不如 SSE)。
3.3 OpenClaw 与 Claude 的本地化部署:绕开云服务的“最后一公里”
Paperclip 的灵魂在于“本地化”,这意味着 OpenClaw 和 Claude 必须能在客户内网服务器上稳定运行。以下是我在 Ubuntu 22.04 和 Windows 11(WSL2)上验证过的最小可行部署方案:
Ubuntu 22.04 部署 OpenClaw + Ollama(Claude)
安装 OpenClaw:
# 下载最新稳定版(v0.8.2) curl -L https://github.com/openclaw/openclaw/releases/download/v0.8.2/openclaw-linux-amd64 -o /usr/local/bin/openclaw chmod +x /usr/local/bin/openclaw # 验证安装 openclaw --version # 输出:openclaw v0.8.2 (built with rustc 1.78.0)安装 Ollama(运行 Claude 模型):
# 官方一键安装 curl -fsSL https://ollama.com/install.sh | sh # 拉取 Claude 模型(注意:官方模型库中是 `claude-3-haiku`,不是 `anthropic/claude-3-haiku`) ollama pull claude-3-haiku # 验证模型可用 echo "Hello" | ollama run claude-3-haiku # 应该输出类似 "Hello! How can I help you today?"关键系统配置:
- 内存限制:Ollama 默认使用全部可用内存,但在 8GB 内存的服务器上,Claude-3-Haiku 可能占满内存导致系统卡死。必须编辑
/etc/ollama/ollama.yml:host: 0.0.0.0:11434 # 限制最大内存为 4GB num_ctx: 4096 # 限制线程数为 4 num_thread: 4 - Swap 分区:强烈建议启用至少 4GB 的 swap 分区,防止 OOM Killer 杀死关键进程。
sudo fallocate -l 4G /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile。
- 内存限制:Ollama 默认使用全部可用内存,但在 8GB 内存的服务器上,Claude-3-Haiku 可能占满内存导致系统卡死。必须编辑
Windows 11(WSL2)部署
启用 WSL2 并安装 Ubuntu 22.04:
# PowerShell 以管理员身份运行 dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart # 重启电脑 wsl --install wsl --set-default-version 2 wsl --install -d Ubuntu-22.04在 WSL2 中安装 OpenClaw 和 Ollama:步骤与 Ubuntu 完全相同。
Windows 主机访问 WSL2 服务:
- Node.js 服务监听
0.0.0.0:3001(不是localhost:3001)。 - 在 Windows 浏览器中访问
http://localhost:3001即可,WSL2 的localhost会自动映射到 Windows 主机。
- Node.js 服务监听
注意:
claude-desktop或claude-code这类桌面应用,Paperclip 架构中明确不推荐使用。原因有三:① 它们是闭源 Electron 应用,无法嵌入到现有 React 界面中;② 它们的本地模型运行在 Windows 原生环境,与 OpenClaw 的 WSL2 环境隔离,无法共享文件系统;③ 它们的 API 不开放,无法进行细粒度的流式控制和错误处理。Paperclip 坚持“命令行优先”,所有能力都必须能通过openclaw xxx和ollama run xxx在终端里复现。
4. Paperclip 架构的实操过程与核心环节实现:从零开始搭建一个可运行的 Demo
4.1 环境准备:一份可复制粘贴的初始化清单
在开始编码前,务必完成以下环境检查。这是 Paperclip 能否跑起来的基石,跳过任何一个步骤,后面都会遇到难以排查的诡异问题。
| 检查项 | 命令 | 期望输出 | 不通过的后果 |
|---|---|---|---|
| Node.js 版本 | node --version | v18.20.4 | 20.x/22.x 版本在流式处理上存在已知 bug,会导致 SSE 连接不稳定 |
| npm 权限 | npm config get prefix | /usr/local(非/home/user/.npm-global) | 如果是用户目录,npm install -g安装的全局包可能不在 PATH 中 |
| OpenClaw 可执行性 | openclaw --help | head -n 5 | 显示帮助信息,包含pdf,obsidian,code等子命令 | 说明 OpenClaw 未正确安装或权限不足 |
| Ollama 服务状态 | systemctl is-active ollama(Ubuntu)或ollama list(WSL2) | active或列出claude-3-haiku | Ollama 未运行,Claude 模型无法调用 |
| 临时目录权限 | ls -ld /tmp/paperclip | drwxrwxrwt(sticky bit) | /tmp目录权限错误会导致文件写入失败 |
提示:
/tmp目录的 sticky bit(t)是必须的,否则不同用户的临时文件会互相覆盖。如果ls -ld /tmp显示-rwxrwxrwx(没有t),请立即执行sudo chmod 1777 /tmp。这是一个 Linux 系统常识,但在容器化部署时容易被忽略。
4.2 初始化项目结构:一个最小但完整的 Paperclip 项目
创建一个名为paperclip-demo的文件夹,结构如下:
paperclip-demo/ ├── client/ # React 前端 │ ├── public/ │ └── src/ │ ├── hooks/ │ │ └── useSSE.ts │ ├── App.tsx │ └── main.tsx ├── server/ # Node.js 胶水层 │ ├── server.js │ ├── package.json │ └── Dockerfile ├── .gitignore └── README.mdStep 1:初始化 Node.js 服务
cd server npm init -y npm install express cors multer npm install --save-dev nodemonpackage.json的scripts部分:
{ "scripts": { "start": "node server.js", "dev": "nodemon --watch server.js server.js" } }Step 2:编写server.js(基于 3.1 节的精简版)
将 3.1 节的代码保存为server.js,并修改tempFilePath的路径为/tmp/paperclip/(确保该目录存在)。
Step 3:启动服务并验证
# 在 server/ 目录下 npm run dev # 应看到 "Paperclip server running on http://localhost:3001" # 手动测试 OpenClaw + Claude 管道 echo "This is a test document." > /tmp/test.txt openclaw text --input /tmp/test.txt | ollama run claude-3-haiku # 应该输出对 "This is a test document." 的回应Step 4:初始化 React 前端
cd ../client npx create-react-app . --template typescript npm install将 3.2 节的useSSEHook 放入src/hooks/useSSE.ts。
修改src/App.tsx:
import React, { useState } from 'react'; import { useSSE } from './hooks/useSSE'; function App() { const [file, setFile] = useState<File | null>(null); const [isUploading, setIsUploading] = useState(false); const { messages