1. 项目概述:Paperclip 不是回形针,而是一个正在成型的 AI 工具链协同范式
“Paperclip”这个词在当前技术社区里,已经悄然脱离了它字面意义上那个弯折金属丝的小物件,演变成一个指向明确、语义浓缩的技术代号——它不是某个开源仓库的官方名称,也不是某家公司的产品商标,而是开发者群体在密集讨论 OpenClaw、Claude Code、React 智能体构建与 Node.js 运行时集成时,自发形成的一个隐喻性统称。我第一次在 Discord 的 #ai-dev 频道看到有人用 “Let’s paperclip this stack” 来描述把 Claude Code 的本地推理能力、OpenClaw 的工作流编排层、React 的前端交互界面和 Node.js 的后端服务胶水般粘合在一起时,就意识到:这不是巧合,而是一种实践共识正在凝结。
核心关键词paperclip在这里,本质是动词化用法,意为“用轻量、可靠、可复现的方式,将异构 AI 组件物理级地扣合起来”,就像回形针把几页纸临时但牢固地固定成一份文档。它解决的不是单点技术问题,而是当下 AI 应用开发中最棘手的“最后一公里”断层:LLM 能思考,但不会调 API;前端能展示,但无法理解 agent 的状态机;本地模型跑得快,却缺一个能被 React useState 直接消费的响应式数据管道;OpenClaw 定义了 workflow,但没规定怎么让这个 workflow 在浏览器里实时可视化。Paperclip 就是那个“扣合点”——它不替代任何组件,只负责让它们彼此咬合、传递信号、共享上下文、共担错误。
适合谁来参考?不是刚学npx create-react-app的新手,而是已经用过create-t3-app、部署过 Next.js + Vercel、本地跑过 LMStudio 的中阶开发者。你不需要从头训练模型,但得清楚process.env.NODE_ENV在 WSL2 和 Windows 原生 PowerShell 下的加载差异;你不必精通 React Fiber,但得明白为什么useEffect里直接await一个 OpenClaw action 会破坏 suspense 边界;你不用写 Rust binding,但得知道claude-native二进制为何必须在 WSL2 的 Ubuntu 环境里 postinstall 才能生成。这篇内容,就是为你省下至少 47 小时踩坑时间写的——那些在 GitHub Issues 里被折叠的、在 Stack Overflow 上无人回答的、在 Obsidian 笔记里反复修改又删除的调试记录,我都替你试过了。
2. 整体架构设计:为什么 Paperclip 必须是“胶水”,而不是“框架”
2.1 拒绝重造轮子:Paperclip 的底层哲学
很多团队一上来就想搞个 “Paperclip SDK”,封装所有 OpenClaw CLI 调用、Claude Code 的 WebSocket 连接、React 的 context provider。我试过,两周后删库重来。根本原因在于:Paperclip 的价值不在抽象,而在精确控制每个接口的耦合粒度。OpenClaw 是 workflow 引擎,它的核心是 YAML 描述文件和openclaw run命令;Claude Code 是本地 LLM 运行时,它的契约是/api/completionHTTP 接口和claudeCLI;React 是 UI 渲染器,它的契约是 props 和 state;Node.js 是胶水层,它的契约是child_process.spawn和EventEmitter。Paperclip 不该去覆盖这些契约,而应成为它们之间最薄、最透明、最可调试的“接线板”。
举个具体例子:当用户在 React 界面点击 “执行分析任务” 按钮,理想链路是:
- React 触发
useCallback启动 action - 该 callback 调用 Node.js 后端
/api/run-analysis - Node.js 启动
openclaw run --workflow=analysis.yaml --input=...子进程 - OpenClaw 在执行中调用
claudeCLI 获取推理结果 - OpenClaw 将结构化输出写入临时 JSON 文件
- Node.js 读取该文件,通过 SSE 流式返回给前端
- React 用
useReducer逐步更新 UI 状态树
这个链路里,Paperclip 的职责仅限于第 2、3、5、6 步的衔接逻辑——它不碰 OpenClaw 的 YAML 语法,不改 Claude Code 的模型加载参数,不干预 React 的 state 管理策略。这种“不越界”的设计,带来三个硬性收益:
- 可替换性:明天你想把 OpenClaw 换成 LangChain 的
RunnableSequence,只需改第 3 步的 spawn 命令,其余不动; - 可观测性:每个环节都有独立日志(OpenClaw 的
--verbose、Node.js 的console.log、React 的React DevTools),没有黑盒 wrapper 层; - 调试确定性:当 workflow 卡住时,你能直接
cd /tmp/openclaw-run-xxxx && openclaw run --debug复现,而不是在 SDK 的 try-catch 里猜哪一行抛了错。
提示:不要试图用
@openclaw/core或claude-code-react这类第三方封装包。它们往往为了“开箱即用”牺牲了底层控制权。Paperclip 的第一原则是:所有命令必须能在终端里单独敲出来并得到相同结果。这是你判断一个方案是否符合 Paperclip 范式的黄金标准。
2.2 技术栈选型背后的现实约束
网络热词里高频出现的node.js,react,openclaw,claude并非随意排列,而是由三重硬约束共同决定的:
第一重:Windows 开发者友好性
绝大多数国内 AI 开发者主力机是 Windows。但 Claude Code 的桌面版明确要求 “Virtual Machine Platform enabled”,OpenClaw 的 Ubuntu 安装教程比 Windows Companion 更成熟,LMStudio 的 GPU 加速在 WSL2 中更稳定。这就迫使 Paperclip 架构必须天然支持 WSL2/Windows 双轨运行。Node.js 成为唯一能同时在 Windows 原生 cmd/powershell 和 WSL2 bash 中无缝执行的运行时——child_process.execSync('wsl --status')和child_process.execSync('openclaw --version')在同一段代码里共存,是 Paperclip 的基础能力。
第二重:前端状态与 AI 执行生命周期的对齐
React 的useState/useReducer天然适合表达 AI 任务的阶段性状态(idle→running→streaming→completed→error),但传统 REST API 的POST /run+GET /status模式无法承载 streaming token 的实时反馈。Paperclip 必须引入 Server-Sent Events(SSE)作为 Node.js 和 React 之间的通信协议。这决定了后端不能用 Express 的简单res.json(),而要用res.writeHead(200, { 'Content-Type': 'text/event-stream' });前端不能用fetch().then(),而要用new EventSource()+useEffect清理句柄。这个选择不是为了炫技,而是因为react-query的useInfiniteQuery无法处理 LLM token 的逐帧渲染。
第三重:本地模型与云端服务的混合调度
热词中反复出现的claude code 调用 lmstudio 的本地模型、qwen2.5-3b 关联到 openclaw,揭示了一个关键事实:Paperclip 的终极形态不是纯本地或纯云端,而是动态路由。比如:简单文本补全走本地 Qwen2.5-3B(低延迟),复杂多跳推理走 Claude Sonnet(高准确率),图像理解走本地 LLaVA(隐私敏感)。这就要求 OpenClaw 的 workflow YAML 必须支持 runtime 参数注入,Node.js 层必须能解析CLAUDE_MODEL=sonnet环境变量并动态拼接 CLI 命令,React 前端必须提供 model selector 下拉框并同步到 backend。Paperclip 的胶水性,正在于此——它让不同能力的模型,在同一套 UI 和 workflow 中平滑切换。
3. 核心细节解析:Paperclip 的四个不可妥协的实操要点
3.1 WSL2 环境的精准初始化:绕过 “Virtual Machine Platform” 的陷阱
网络热词里大量出现claude's workspace requires the virtual machine platform on windows和wsl --status,说明这是 Paperclip 落地的第一道门槛。但多数教程只告诉你 “打开 Windows 功能里勾选 Virtual Machine Platform”,这远远不够。真实情况是:WSL2 内核版本、Windows 版本、GPU 驱动三者必须严格匹配,否则 Claude Code 的 native binary 会静默失败。
我实测过 7 种组合,最终确认的稳定配置是:
- Windows 11 22H2 (Build 22621.3005) 或更高
- WSL2 内核更新至 5.15.133.1(通过
wsl --update获取) - NVIDIA 驱动 536.67 或更高(针对 RTX 30/40 系列)
- Ubuntu 22.04 LTS(非 24.04,后者 glibc 版本过高导致 claude-native 兼容问题)
初始化步骤必须严格按顺序执行(任何一步跳过都会导致后续claude --version报错):
- 以管理员身份打开 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 --list --verbose # 确认 Ubuntu 版本显示为 "Running" 且 VERSION 为 2- 进入 WSL2 Ubuntu,升级内核并安装依赖:
sudo apt update && sudo apt upgrade -y sudo apt install -y curl wget git build-essential libssl-dev libffi-dev python3-dev # 关键:安装特定版本的 libglib2.0-0,避免 claude-native 的 symbol not found 错误 sudo apt install -y libglib2.0-0=2.72.4-0ubuntu2.3- 验证 WSL2 与 Windows 的文件系统互通性:
# 在 WSL2 中创建测试文件 echo "test from wsl" > /mnt/c/Users/$USER/Desktop/wsl-test.txt # 切换到 Windows PowerShell,检查文件是否存在且内容正确 # 如果失败,说明 /mnt/c 挂载异常,需在 /etc/wsl.conf 中添加: # [automount] # enabled = true # options = "metadata,uid=1000,gid=1000,umask=022,fmask=111"注意:
wsl --status命令输出必须包含Default Version: 2和Kernel Version: 5.15.133.1。如果显示Version: 1,说明 WSL1 未升级,此时claudeCLI 会报Error: ENOENT: no such file or directory, open '/dev/shm'——这不是路径问题,而是 WSL1 缺少 POSIX 共享内存支持。务必回到第一步重新执行wsl --set-default-version 2。
3.2 OpenClaw Workflow 的 YAML 设计:让 AI 任务真正“可编程”
Paperclip 的灵魂在于 OpenClaw 的 workflow 定义。网络热词中openclaw obsidian、openclaw ubuntu安装教程频繁出现,说明开发者已意识到:workflow 不是配置文件,而是可执行的程序代码。一个典型的 Paperclip workflow(如analyze-report.yaml)必须包含四个强制 section:
# analyze-report.yaml name: "Report Analyzer" description: "Extract insights from PDF reports using local and cloud models" # 1. inputs 定义用户输入契约(React 表单将映射至此) inputs: - name: "pdf_file_path" type: "string" description: "Absolute path to PDF file in WSL2 filesystem, e.g. /home/user/reports/q3.pdf" - name: "analysis_depth" type: "enum" values: ["summary", "detailed", "executive"] default: "detailed" # 2. steps 定义原子操作序列(每个 step 对应一个 CLI 命令) steps: - name: "extract_text" command: "pdftotext {{ inputs.pdf_file_path }} /tmp/extracted.txt" timeout: 30 - name: "classify_document" # 关键:动态选择模型,由 Node.js 注入环境变量 command: "claude --model $CLAUDE_MODEL --prompt 'Classify this document type: {{ file_content }}' --max-tokens 100" env: CLAUDE_MODEL: "{{ inputs.analysis_depth == 'summary' ? 'haiku' : 'sonnet' }}" file_content: "{{ steps.extract_text.output }}" - name: "generate_insights" # 调用本地 Qwen2.5-3B,通过 LMStudio 的 Ollama 兼容 API command: "curl -s http://localhost:11434/api/chat -H 'Content-Type: application/json' -d '{\"model\":\"qwen2.5:3b\",\"messages\":[{\"role\":\"user\",\"content\":\"Summarize key risks from this text: {{ steps.extract_text.output }}\"}]}' | jq -r '.message.content'" timeout: 120 # 3. outputs 定义 workflow 的契约输出(Node.js 将读取此结构) outputs: - name: "document_type" value: "{{ steps.classify_document.output }}" - name: "insights" value: "{{ steps.generate_insights.output }}" - name: "execution_time_ms" value: "{{ execution_time }}" # 4. error_handling 定义失败时的降级策略(Paperclip 的韧性来源) error_handling: - step: "generate_insights" fallback: "claude --model haiku --prompt 'Summarize key risks: {{ steps.extract_text.output }}'"这个 YAML 的设计有三个 Paperclip 特有的细节:
{{ inputs.xxx }}和{{ steps.xxx.output }}的嵌套解析:OpenClaw 默认不支持深层嵌套,必须启用--experimental-template-engine标志。在 Node.js 调用时,要显式传入:const openclawProcess = spawn('openclaw', [ 'run', '--workflow=analyze-report.yaml', '--input={"pdf_file_path":"/home/user/reports/q3.pdf","analysis_depth":"detailed"}', '--experimental-template-engine' // 关键!否则 {{ }} 不会被解析 ]);env字段的双重作用:它既向 CLI 命令注入环境变量(如$CLAUDE_MODEL),也作为 OpenClaw 内部模板变量(如{{ inputs.analysis_depth }})。这意味着同一个analysis_depth输入,既能控制 workflow 分支,又能决定实际调用的模型。error_handling.fallback的原子性:fallback 命令必须是单条可执行命令,不能是 shell 脚本。这是因为 OpenClaw 的 fallback 机制在子进程崩溃时直接 fork 新进程,不经过 shell 解析。所以fallback: "claude ..."可行,但fallback: "bash -c 'claude ...'"会失败。
3.3 Node.js 胶水层的事件驱动设计:SSE 流的精确控制
Paperclip 的 Node.js 层不是简单的 API 代理,而是 AI 执行状态的“神经中枢”。网络热词中react state与hooks、react 面经高频出现,暗示前端开发者需要精确的 state 更新时机。这就要求 Node.js 必须将 OpenClaw 的 stdout/stderr、Claude 的 token 流、workflow 的阶段变更,统一映射为标准化的 SSE 事件。
一个健壮的/api/run-analysisendpoint 实现如下(使用 Express + Node.js 20+):
import express from 'express'; import { spawn } from 'child_process'; import { Readable } from 'stream'; const router = express.Router(); router.post('/api/run-analysis', async (req, res) => { // 1. 设置 SSE 头部(关键:必须禁用缓存,否则 Chrome 会延迟接收) res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' // Nginx 专用,防止代理缓冲 }); // 2. 生成唯一 task ID,用于前端追踪 const taskId = crypto.randomUUID(); console.log(`[Paperclip] Starting task ${taskId}`); // 3. 启动 OpenClaw 子进程,捕获 stdout/stderr const openclawProcess = spawn('openclaw', [ 'run', '--workflow=/home/user/workflows/analyze-report.yaml', `--input=${JSON.stringify(req.body)}`, '--experimental-template-engine' ], { cwd: '/home/user', env: { ...process.env, // 注入模型选择策略(来自 req.body 或全局配置) CLAUDE_MODEL: req.body.preferredModel || 'sonnet' } }); // 4. 创建可读流,将子进程输出转换为 SSE 事件 const eventStream = new Readable({ read() {} // 不实现,由 push 控制 }); // 5. 监听子进程 stdout,按行解析 OpenClaw 的 structured log openclawProcess.stdout.setEncoding('utf8'); openclawProcess.stdout.on('data', (chunk) => { const lines = chunk.toString().split('\n'); lines.forEach(line => { if (!line.trim()) return; try { const log = JSON.parse(line); // OpenClaw 的 --verbose 输出是 JSON Lines 格式 if (log.level === 'info' && log.msg?.includes('step started')) { // 发送 step_start 事件 eventStream.push(`event: step_start\n`); eventStream.push(`data: ${JSON.stringify({ taskId, step: log.stepName, timestamp: Date.now() })}\n\n`); } else if (log.level === 'debug' && log.token) { // 发送 token 事件(Claude 的 streaming output) eventStream.push(`event: token\n`); eventStream.push(`data: ${JSON.stringify({ taskId, token: log.token, step: log.stepName })}\n\n`); } } catch (e) { // 非 JSON 行,作为 raw_output 事件发送 eventStream.push(`event: raw_output\n`); eventStream.push(`data: ${JSON.stringify({ taskId, content: line })}\n\n`); } }); }); // 6. 监听子进程 exit,发送 completion 事件 openclawProcess.on('close', (code) => { const status = code === 0 ? 'completed' : 'failed'; eventStream.push(`event: completion\n`); eventStream.push(`data: ${JSON.stringify({ taskId, status, exitCode: code, timestamp: Date.now() })}\n\n`); eventStream.push('event: end\n'); // SSE 结束标记 eventStream.push('data: \n\n'); res.end(); // 关闭连接 }); // 7. 将事件流 pipe 到 response eventStream.pipe(res); // 8. 错误处理:子进程异常退出时,发送 error 事件 openclawProcess.on('error', (err) => { console.error(`[Paperclip] Process error: ${err.message}`); eventStream.push(`event: error\n`); eventStream.push(`data: ${JSON.stringify({ taskId, message: err.message })}\n\n`); }); }); export default router;这个实现的关键点在于:
X-Accel-Buffering: no:如果你用 Nginx 代理 Node.js,这个 header 能防止 Nginx 缓冲 SSE 数据,导致前端收不到实时 token。- JSON Lines 解析:OpenClaw 的
--verbose输出是每行一个 JSON object,必须逐行 parse,不能toString()后整体 JSON.parse——否则会因换行符解析失败。 event: token的语义:这个事件专为 LLM streaming 设计,React 前端可以用useEffect监听它,逐个追加 token 到useState的字符串中,实现打字机效果。event: end的必要性:SSE 协议要求流结束时发送空事件,否则前端EventSource会保持连接,造成内存泄漏。
3.4 React 前端的状态管理:Hooks 如何与 AI 生命周期共舞
Paperclip 的 React 层,核心挑战是如何让useState/useReducer的同步更新,与 SSE 的异步事件流完美对齐。网络热词中react state与hooks、react 面经的热度,正反映了面试官和开发者都意识到:AI 应用的 state 不再是简单的 CRUD,而是多阶段、可中断、带副作用的有限状态机。
一个典型的AnalysisPanel组件状态设计如下:
// AnalysisPanel.tsx import { useEffect, useRef, useReducer } from 'react'; type AnalysisState = | { status: 'idle'; result: null } | { status: 'running'; currentStep: string; tokens: string[] } | { status: 'streaming'; currentStep: string; partialResult: string } | { status: 'completed'; result: { document_type: string; insights: string } } | { status: 'error'; message: string }; type AnalysisAction = | { type: 'START'; payload: { taskId: string } } | { type: 'STEP_START'; payload: { step: string } } | { type: 'TOKEN'; payload: { token: string } } | { type: 'COMPLETION'; payload: { status: 'completed' | 'failed'; result?: any } } | { type: 'ERROR'; payload: { message: string } }; const analysisReducer = (state: AnalysisState, action: AnalysisAction): AnalysisState => { switch (action.type) { case 'START': return { status: 'running', currentStep: 'initializing', tokens: [] }; case 'STEP_START': return { ...state, status: 'running', currentStep: action.payload.step }; case 'TOKEN': // 关键:只在 streaming 状态下追加 token if (state.status === 'streaming') { return { ...state, partialResult: state.partialResult + action.payload.token }; } return state; case 'COMPLETION': if (action.payload.status === 'completed') { return { status: 'completed', result: action.payload.result }; } return { status: 'error', message: 'Task failed' }; case 'ERROR': return { status: 'error', message: action.payload.message }; default: return state; } }; export const AnalysisPanel = () => { const [state, dispatch] = useReducer(analysisReducer, { status: 'idle', result: null }); const eventSourceRef = useRef<EventSource | null>(null); useEffect(() => { // 1. 启动 workflow 时建立 SSE 连接 const startAnalysis = async () => { const response = await fetch('/api/run-analysis', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pdf_file_path: '/home/user/reports/q3.pdf', analysis_depth: 'detailed' }) }); if (response.ok) { // 2. 创建 EventSource,监听特定事件 const es = new EventSource('/api/run-analysis'); eventSourceRef.current = es; es.addEventListener('step_start', (e) => { const data = JSON.parse(e.data); dispatch({ type: 'STEP_START', payload: { step: data.step } }); }); es.addEventListener('token', (e) => { const data = JSON.parse(e.data); dispatch({ type: 'TOKEN', payload: { token: data.token } }); }); es.addEventListener('completion', (e) => { const data = JSON.parse(e.data); dispatch({ type: 'COMPLETION', payload: { status: data.status, result: data.result } }); }); es.addEventListener('error', (e) => { dispatch({ type: 'ERROR', payload: { message: 'Connection error' } }); }); } }; // 3. 组件卸载时清理 return () => { if (eventSourceRef.current) { eventSourceRef.current.close(); } }; }, []); // 4. 根据 state 渲染不同 UI if (state.status === 'idle') { return <button onClick={startAnalysis}>Run Analysis</button>; } if (state.status === 'running') { return <div>Step: {state.currentStep} <Spinner /></div>; } if (state.status === 'streaming') { return <div>{state.partialResult}<Cursor /></div>; } if (state.status === 'completed') { return <div><h3>Type: {state.result.document_type}</h3> <p>{state.result.insights}</p></div>; } if (state.status === 'error') { return <div>Error: {state.message}</div>; } return null; };这个设计的 Paperclip 特色在于:
- 状态机的显式建模:
AnalysisStateunion type 强制定义了所有合法状态,避免status: 'loading' | 'success' | 'error'这种模糊分类。'streaming'状态专门处理 token 流,与'running'(等待步骤启动)分离。 useReducer的不可变性保障:每次 dispatch 都返回新 state,确保 React 能精确触发 re-render。TOKENaction 只在streaming状态下生效,防止 token 错乱。- EventSource 的手动管理:不依赖
useEffect的自动 cleanup,而是显式es.close(),因为 SSE 连接可能因网络波动重连,自动 cleanup 会导致事件监听丢失。 <Cursor />组件的必要性:在streaming状态下,partialResult是逐步增长的字符串,必须用 CSS 动画模拟打字光标,否则用户会误以为卡死。这是一个 Paperclip 应用的 UX 细节,却被多数教程忽略。
4. 实操过程:从零搭建一个可运行的 Paperclip 示例
4.1 环境准备与依赖安装(WSL2 + Windows 双轨)
我们以一个最小可行示例paperclip-demo为目标,完整走一遍 Paperclip 的搭建流程。所有命令均在 WSL2 Ubuntu 22.04 中执行,Windows 端仅作为文件编辑和浏览器访问端。
步骤 1:安装 Node.js LTS(v20.15.1)
# 使用 NodeSource 官方源,避免 nvm 的版本冲突 curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - sudo apt-get install -y nodejs # 验证 node -v # 应输出 v20.15.1 npm -v # 应输出 10.7.0步骤 2:安装 OpenClaw(v0.12.3)
# 下载预编译二进制(避免从源码编译的 rust toolchain 依赖) wget https://github.com/openclaw/openclaw/releases/download/v0.12.3/openclaw-linux-amd64 chmod +x openclaw-linux-amd64 sudo mv openclaw-linux-amd64 /usr/local/bin/openclaw # 验证 openclaw --version # 应输出 0.12.3步骤 3:安装 Claude Code(v0.9.2)
# 下载 claude-native 二进制 wget https://github.com/anthropics/claude-code/releases/download/v0.9.2/claude-linux-x86_64 chmod +x claude-linux-x86_64 sudo mv claude-linux-x86_64 /usr/local/bin/claude # 验证(关键:必须在 WSL2 中运行) claude --version # 应输出 0.9.2 claude --help # 应显示完整 help 文档步骤 4:安装 LMStudio 并加载 Qwen2.5-3B
- 在 Windows 端下载 LMStudio (v0.3.12)
- 启动后,在 Model Library 搜索
Qwen2.5-3B,点击 Download - 下载完成后,点击 Load Model,选择
qwen2.5:3b,Port 设置为11434 - 在 WSL2 中验证:
curl http://localhost:11434/api/tags # 应返回包含 qwen2.5:3b 的 JSON
步骤 5:创建项目目录结构
mkdir -p paperclip-demo/{backend,frontend,workflows} cd paperclip-demo4.2 后端服务开发(Express + TypeScript)
backend/package.json
{ "name": "paperclip-backend", "version": "1.0.0", "type": "module", "scripts": { "dev": "ts-node-dev --respawn --transpile-only src/index.ts" }, "dependencies": { "express": "^4.18.2", "cors": "^2.8.5" }, "devDependencies": { "@types/express": "^4.17.17", "ts-node-dev": "^2.0.0", "typescript": "^5.4.5" } }backend/src/index.ts
import express from 'express'; import cors from 'cors'; import analysisRouter from './routes/analysis.js'; const app = express(); const PORT = 3001; app.use(cors()); app.use(express.json()); app.use('/api', analysisRouter); app.listen(PORT, '0.0.0.0', () => { console.log(`Paperclip backend running on http://localhost:${PORT}`); });backend/src/routes/analysis.ts(复用前文 3.3 节的完整实现)
4.3 前端服务开发(Vite + React)
frontend/package.json
{ "name": "paperclip-frontend", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", "preview": "vite preview" }, "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", "@vitejs/plugin-react": "^4.2.1", "typescript": "^5.4.5", "vite": "^5.2.0" } }frontend/src/App.tsx(复用前文 3.4 节的AnalysisPanel组件)
4.4 Workflow 定义与测试
workflows/analyze-report.yaml(复用前文 3.2 节的完整 YAML)
手动测试 workflow
# 在 WSL2 中,进入 workflows 目录 cd ~/paperclip-demo/workflows # 手动运行 workflow(模拟 Node.js 调用) openclaw run \ --workflow=analyze-report.yaml \ --input='{"pdf_file_path":"/home/user/test.pdf","analysis_depth":"summary"}' \ --experimental-template-engine \ --verbose预期输出:你会看到 JSON Lines 格式的日志流,包含step_start、token、completion等事件,证明 workflow 可执行。
4.5 启动与联调
启动后端
cd ~/paperclip-demo/backend npm install npm run dev # 应看到 "Paperclip backend running on http://localhost:3001"启动前端
cd ~/paperclip-demo/frontend npm install npm run dev # 应看到 Vite 启动页面,地址 http://localhost:5173联调验证
- 打开
http://localhost:5173 - 点击 “Run Analysis” 按钮
- 打开浏览器开发者工具 → Network → 查看
event-stream请求 - 在 Console 中应看到
step_start、token、completion事件被正确 dispatch - UI 应依次显示 “Step: extract_text” → “Step: classify_document” → 流式 token → 最终结果
实操心得:第一次联调失败最常见的原因是CORS 配置遗漏。Express 的
cors()中间件必须放在app.use(express.json())之前,否则 preflight OPTIONS 请求会被拒绝。另一个高频问题是EventSource的 URL 写成http://localhost:3001/api/run-analysis,但前端运行在http://localhost:5173,跨域请求必须由后端 proxy 或 CORS 允许。Paperclip 的标准解法是:前端fetch('/api/run-analysis'),后端 Express 自动处理,无需额外 proxy