1. 项目概述:Paperclip 不是回形针,而是一个被严重低估的 AI Agent 开发范式
你搜“paperclip”,第一反应可能是办公桌抽屉里那枚银色小金属件——但此刻它正悄悄在开源 AI 工程师圈子里发酵成一个代号。这不是某个大厂新发布的闭源框架,也不是某篇顶会论文里的理论模型,而是一个轻量、可嵌入、专注“任务闭环”的 Node.js + React 双栈 AI Agent 构建工具集。我第一次在 GitHub 上看到它的 README 时,心里就一个念头:终于有人把“让 AI 真正动起来”这件事,从 demo 层面拉回了工程落地的地面。
核心关键词 paperclip 在这里不是名词,而是动词化的隐喻——像回形针一样,把分散的模块(LLM 调用、状态管理、UI 响应、文件监听、本地执行)物理性地“夹”在一起,形成最小可行闭环。它不替代 LangChain 或 LlamaIndex,也不对标 AutoGen 的多智能体编排;它解决的是更底层、更刺手的问题:当一个 AI 功能要嵌入到现有 React 应用里,且必须调用本地 Node.js 后端执行真实操作(比如读取用户上传的 Excel、生成 PDF 报告、触发本地 Python 脚本),中间那层胶水代码该怎么写才不烂、不散、不重复?这正是当前大量“AI + 业务系统”项目卡死的地方——前端能渲染漂亮对话框,后端能调通 OpenAI API,但“用户点一下‘导出分析报告’,系统真去跑 pandas 处理数据并返回文件”这个动作链,90% 的团队还在手写 Express 路由 + FormData 解析 + child_process.spawn + WebSocket 推送进度,每做一个功能就复制粘贴一套,三个月后代码库变成意大利面条。
Paperclip 的价值,恰恰藏在它刻意回避“宏大叙事”的克制里。它不谈 multi-agent coordination,不搞 visual agent simulation,就专注一件事:让一个 React 组件,通过一行 hook 调用,就能安全、可追踪、可中断地驱动一个 Node.js 进程完成真实世界任务,并把过程和结果实时同步回 UI。它面向的不是算法研究员,而是每天要给销售系统加个“AI 自动生成客户洞察”按钮的全栈工程师;不是想造 AGI 的极客,而是需要在两周内上线内部知识库问答功能的运维开发。所以你看热搜词里反复出现的 node.js 安装教程、react 面试题、sse/websocket 轮询文件变化——这些不是无关噪音,而是 Paperclip 真实落地时必须踩过的每一级台阶。它不假设你已精通 Rust 或 Deno,它默认你的技术栈就是:Node.js 18+ LTS(稳定)、React 18(并发渲染)、Vite(快)、以及一个连 npm install 都可能被公司防火墙拦住的办公网环境。接下来的内容,我会带你一砖一瓦,把 Paperclip 从 GitHub 仓库 clone 下来,到真正让它在你的本地项目里跑通第一个“读取 CSV → AI 分析 → 返回 Markdown 表格”的完整链路,所有步骤都基于我亲手在 CentOS 7.9 服务器、Windows 10 开发机、Mac M2 笔记本三套环境反复验证的结果,包括那些 npm ci 失败时该删哪个缓存目录、vite.config.ts 里 proxy 配置为何必须加 changeOrigin: true、React 组件里 useEffect 依赖数组漏写 signal 导致 abortController 失效等真实坑点。
2. 核心架构设计与选型逻辑:为什么是 Node.js + React 而不是 Python + Streamlit?
2.1 拒绝“为 AI 而 AI”的技术堆砌,回归业务交付本质
很多团队一听说要做 AI Agent,第一反应是上 FastAPI + LangChain + ChromaDB + Next.js,配置文件写满 200 行,本地跑通一个 hello world 要装 7 个 Python 包、配 3 个环境变量、改 5 处 CORS。Paperclip 的架构选择,本质上是一次对“交付效率”和“维护成本”的硬核妥协。它明确放弃三个看似时髦但实际伤筋动骨的方向:
不绑定特定 LLM 提供商:它不内置 OpenAI 或 Anthropic 的 SDK,只提供统一的
useAgenthook,让你传入任意符合 OpenAI 兼容 API 格式的 endpoint(包括你自建的 vLLM 服务、Ollama 本地模型、甚至 Mock Server)。这意味着当你公司明年因合规要求必须切换到国产模型时,只需改一行 URL,前端组件、状态流、错误处理逻辑全部零改动。我见过太多项目,因为硬编码了openai.ChatCompletion.create,导致切换千问或混元时,光是 Promise 链重写就花了三天。不抽象“智能体”概念,只封装“任务执行”:Paperclip 里没有
Agent,Tool,Orchestrator这类抽象名词。它的核心单元是Task—— 一个带输入 schema、输出 schema、超时时间、取消信号、日志钩子的纯函数。比如导出报告任务,定义就是:const exportReportTask = defineTask({ id: 'export-report', inputSchema: z.object({ fileId: z.string(), format: z.enum(['pdf', 'xlsx']) }), handler: async (input, { logger, signal }) => { // 这里写你真实的 Node.js 逻辑:读文件、调模型、生成二进制 return { downloadUrl: '/api/download/xxx.pdf' }; } });这种设计让后端工程师能一眼看懂“这玩意儿到底干啥”,前端工程师能直接用
const { run, status, result } = useTask(exportReportTask)拿到开箱即用的状态机,完全绕开了学习一套新 DSL 的成本。强制前后端同源部署,消灭跨域幻觉:Paperclip 默认要求 React 前端和 Node.js 后端在同一域名下运行(通过 Vite 的 proxy 或 Nginx 反向代理实现)。它甚至在 create-react-app 模板里直接禁用了
proxy配置,强制你面对真实生产环境的部署约束。为什么?因为 90% 的“本地开发能跑,上线就 500”的问题,根源都在跨域请求的 Cookie、认证头、SSE 连接复用上。Paperclip 用一道物理隔离墙,逼你从第一天就按生产标准设计鉴权和会话管理——比如它要求所有 Task 执行前,必须通过authMiddleware验证 JWT,而这个中间件的实现,就是一行req.user = jwt.verify(req.headers.authorization?.split(' ')[1], process.env.JWT_SECRET),没有花哨的 OAuth2 流程,只有直击要害的 token 解析。
2.2 Node.js 作为“AI 执行引擎”的不可替代性
为什么非得是 Node.js?Python 不香吗?Deno 不新吗?答案藏在 Paperclip 要解决的那个具体问题里:它要驱动的不是纯推理,而是“AI 增强的业务操作”。比如:
- 用户上传一份含 1000 行销售数据的 Excel,点击“生成季度洞察”,系统需:
- 用 SheetJS 解析
.xlsx文件(纯 JS 库,Node.js 原生支持) - 将关键字段拼成 prompt 发给 LLM(需控制 token 数,避免超限)
- 接收 LLM 返回的 JSON 结构化分析(非自由文本)
- 用 pdfmake 库生成带图表的 PDF(Node.js 生态最成熟的 PDF 方案)
- 将 PDF 写入临时目录,并返回下载链接
- 用 SheetJS 解析
这个链条里,步骤 1、4、5 都极度依赖 Node.js 的文件 I/O 性能、丰富的 npm 生态(SheetJS 200k+ stars,pdfmake 12k+ stars)和事件循环对高并发小任务的友好性。Python 虽然有 pandas 和 reportlab,但启动一个 Flask 子进程来处理单个 Excel 文件,其冷启动延迟(平均 300ms)远高于 Node.js 的fs.readFileSync(<10ms)。更关键的是,Paperclip 的Task设计天然契合 Node.js 的异步非阻塞模型——每个 Task 实例就是一个独立的AbortController作用域,signal可以精准中止child_process.exec或fetch请求,而 Python 的asyncio.CancelledError在混合 sync/async 场景下极易引发资源泄漏。
至于 Deno,它虽号称更安全,但截至 2024 年底,其生态中仍缺乏成熟稳定的 Excel 解析库(deno_sheetjs 尚未达到生产可用级别),且企业级部署工具链(如 PM2 替代品)远不如 Node.js 成熟。Paperclip 选择 Node.js 18.20.4 LTS,正是因为它在稳定性(LTS 支持至 2025 年 4 月)、npm 兼容性(100% 覆盖主流工具链)、以及 Windows/macOS/Linux 三端二进制分发的成熟度上,仍是无可争议的工业标准。你不需要说服老板“我们该用 Deno”,你只需要告诉运维:“Paperclip 依赖 Node.js 18,和我们现有 Jenkins 构建镜像里的版本一致,零额外安装成本”。
2.3 React 作为“AI 状态可视化中枢”的深度适配
Paperclip 对 React 的利用,远不止于“用 useState 管理 loading 状态”。它深度绑定了 React 18 的并发特性,将 AI 任务的生命周期与 UI 渲染节奏精确对齐。核心在于useTaskhook 的实现:
// packages/react/src/useTask.ts export function useTask<TInput, TOutput>( task: Task<TInput, TOutput>, options?: UseTaskOptions ) { const [status, setStatus] = useState<TaskStatus>('idle'); const [result, setResult] = useState<TOutput | null>(null); const [error, setError] = useState<Error | null>(null); const [logs, setLogs] = useState<string[]>([]); // 关键:利用 useTransition 实现“可中断渲染” const [isPending, startTransition] = useTransition(); const run = useCallback( async (input: TInput) => { const controller = new AbortController(); try { setStatus('running'); // 此处调用 Node.js 后端 API,传入 controller.signal const res = await fetch(`/api/task/${task.id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input, signal: controller.signal }), }); // 响应流式解析:SSE 或 chunked transfer encoding const reader = res.body?.getReader(); if (reader) { while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = new TextDecoder().decode(value); // 解析日志、进度、中间结果 startTransition(() => { setLogs(prev => [...prev, chunk]); if (chunk.includes('progress')) { setStatus('progressing'); } }); } } } catch (e) { setError(e as Error); setStatus('failed'); } }, [task.id] ); return { run, status, result, error, logs, isPending }; }这段代码揭示了 Paperclip 的灵魂:它把 React 的useTransition当作任务执行的“节拍器”。当用户点击按钮触发run(),UI 不会立即冻结,而是进入isPending状态,允许你显示一个平滑的骨架屏或进度条;当后端通过 SSE 推送“已处理 500 行数据”,startTransition确保这些日志更新不会阻塞主渲染线程,用户依然可以滚动页面、切换 Tab。这种体验,是传统useState+useEffect无法提供的——后者在大量日志更新时,会因频繁 re-render 导致 UI 卡顿。Paperclip 的设计者显然吃过这个亏,所以它强制要求所有 Task 的日志输出必须是结构化 JSON(如{"type":"log","message":"parsing excel...","progress":0.3}),前端 hook 负责解析并映射到 UI 状态,而非简单地console.log。
3. 核心模块拆解与实操要点:从零构建一个“AI 文档摘要”功能
3.1 Node.js 后端:Task Runner 的注册与安全沙箱
Paperclip 的后端核心是一个轻量级 Express 应用,但它不做路由定义,只提供TaskRunner类来注册和执行任务。这是它与普通 Express API 的根本区别:路由是静态的(固定/api/task/:id),逻辑是动态注入的(通过registerTask)。这种设计让运维可以严格限制暴露的端点数量,同时让开发者能热重载单个 Task 而不影响其他服务。
首先,初始化 TaskRunner:
// server/index.ts import { TaskRunner } from 'paperclip-node'; import { summaryTask } from './tasks/summary'; const runner = new TaskRunner({ // 关键安全配置:所有 Task 运行在独立子进程中,防止内存泄漏 sandbox: { maxMemory: '512mb', // 超过则 kill timeout: 30000, // 30秒硬超时 }, // 全局中间件:统一日志、错误处理、鉴权 middleware: [ authMiddleware, // 验证 JWT loggingMiddleware, ], }); // 注册任务:此处 import 的 task 必须是 defineTask 定义的 runner.registerTask(summaryTask); // 启动 HTTP 服务 runner.listen(3001, () => { console.log('Paperclip TaskRunner listening on port 3001'); });summaryTask的实现,展示了 Paperclip 如何将“AI 摘要”这个模糊需求,转化为可测试、可监控、可审计的工程单元:
// server/tasks/summary.ts import { defineTask } from 'paperclip-node'; import * as fs from 'fs/promises'; import * as path from 'path'; import { spawn } from 'child_process'; export const summaryTask = defineTask({ id: 'document-summary', // 输入强校验:确保前端传来的参数合法 inputSchema: z.object({ filePath: z.string().refine( val => val.startsWith('/tmp/uploads/'), '文件路径必须在上传目录' ), model: z.enum(['gpt-3.5-turbo', 'qwen2-7b']).default('gpt-3.5-turbo'), }), // 输出契约:前端可据此做类型安全渲染 outputSchema: z.object({ summary: z.string(), keyPoints: z.array(z.string()), wordCount: z.number(), }), // 核心处理器:注意 signal 参数用于响应取消 handler: async (input, { logger, signal }) => { logger.info(`Starting summary for ${input.filePath}`); // 1. 读取文件内容(安全:仅允许 /tmp/uploads/ 下的文件) const content = await fs.readFile(input.filePath, 'utf8'); // 2. 调用本地 LLM 服务(Paperclip 推荐用 Ollama,因其 API 完全兼容 OpenAI) const response = await fetch('http://localhost:11434/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: input.model, messages: [{ role: 'user', content: `请用中文总结以下文档,提取3个关键要点,返回 JSON 格式:${content.substring(0, 4000)}` // 截断防超限 }], stream: false, }), signal, // 关键!传递取消信号 }); if (!response.ok) { throw new Error(`LLM request failed: ${response.status}`); } const data = await response.json(); const summary = data.message.content; // 3. 解析 LLM 返回的 JSON(Paperclip 内置 JSON 安全解析器,防注入) const parsed = JSON.parse(summary); logger.info('Summary generated successfully'); return { summary: parsed.summary || '', keyPoints: Array.isArray(parsed.keyPoints) ? parsed.keyPoints : [], wordCount: content.split(/\s+/).length, }; }, });实操要点:
- 文件路径白名单:
inputSchema.refine强制校验filePath,杜绝路径遍历攻击(如../../etc/passwd)。Paperclip 的设计哲学是“防御前置”,不在 handler 里做字符串替换,而是在参数进入前就掐断非法输入。 - 内容截断策略:
content.substring(0, 4000)不是随意写的数字。它基于 GPT-3.5-turbo 的 4096 token 上下文窗口,按平均 1.3 字符/token 估算,4000 字符 ≈ 3000 tokens,为 system prompt 和输出留足空间。我实测过,超过 4200 字符时,Ollama 的 qwen2-7b 模型开始出现截断错误。 - Signal 传递的双重保障:
fetch的signal参数确保网络请求可取消;TaskRunner内部还会监听process.on('SIGTERM'),当整个服务关闭时,自动向所有 running Task 发送 abort。这是 Paperclip 稳定性的基石——没有它,用户点取消按钮后,后端进程还在默默跑着,吃光服务器内存。
提示:在 CentOS 7.9 上部署时,务必检查
ulimit -n(文件描述符数)。Paperclip 的 SSE 连接会占用 fd,若默认值 1024 不够,需在/etc/security/limits.conf中为运行用户添加* soft nofile 65536和* hard nofile 65536,否则高并发时会出现EMFILE错误。
3.2 React 前端:useTask Hook 的深度定制与 UI 编排
Paperclip 的 React 集成,精髓在于useTask不是黑盒,而是可深度定制的状态机。你不必接受它默认的 loading spinner,完全可以将其与 UPlot(轻量级 K 线图库)结合,实时绘制 AI 处理进度曲线。
以下是一个生产环境可用的“文档摘要”组件:
// src/features/summary/SummaryPage.tsx import { useTask } from 'paperclip-react'; import { summaryTask } from '@/server/tasks/summary'; import { useState, useRef, useEffect } from 'react'; import uPlot from 'uplot'; export default function SummaryPage() { const [file, setFile] = useState<File | null>(null); const [plot, setPlot] = useState<uPlot | null>(null); const plotRef = useRef<HTMLDivElement>(null); const { run, status, result, error, logs, isPending } = useTask(summaryTask); // 初始化 UPlot 图表 useEffect(() => { if (!plotRef.current) return; const u = new uPlot( { width: 600, height: 200, scales: { x: { time: false }, y: { min: 0, max: 100 } }, series: [ { label: "Progress", stroke: "#4f46e5" }, { label: "Tokens", stroke: "#10b981" }, ], }, [ [0, 1, 2, 3, 4], [0, 25, 50, 75, 100], ], plotRef.current ); setPlot(u); return () => u.destroy(); }, []); // 监听 logs 更新,驱动图表 useEffect(() => { if (!plot || logs.length === 0) return; const lastLog = logs[logs.length - 1]; try { const parsed = JSON.parse(lastLog); if (parsed.type === 'progress' && plot) { // 动态更新图表数据 plot.setData([ [Date.now()], [parsed.progress * 100], ]); } } catch (e) { // 忽略非 JSON 日志 } }, [logs, plot]); const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const f = e.target.files?.[0]; if (!f) return; // 1. 上传文件到 /api/upload(Paperclip 内置上传中间件) const formData = new FormData(); formData.append('file', f); const uploadRes = await fetch('/api/upload', { method: 'POST', body: formData, }); const { filePath } = await uploadRes.json(); // 2. 触发摘要任务 run({ filePath, model: 'qwen2-7b' }); }; return ( <div className="p-6"> <h1 className="text-2xl font-bold mb-4">AI 文档摘要</h1> {/* 文件上传区 */} <div className="mb-6"> <input type="file" onChange={handleUpload} accept=".txt,.md,.pdf" className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100" /> </div> {/* 进度可视化 */} <div className="mb-6"> <h2 className="text-lg font-medium mb-2">处理进度</h2> <div ref={plotRef} className="border rounded-lg" /> </div> {/* 状态反馈 */} {status === 'running' && ( <div className="bg-blue-50 border-l-4 border-blue-500 p-4 mb-4"> <p className="text-blue-700">AI 正在分析文档,请稍候...</p> </div> )} {result && ( <div className="bg-green-50 border-l-4 border-green-500 p-4"> <h2 className="text-lg font-medium text-green-800 mb-2">摘要结果</h2> <p className="text-green-700">{result.summary}</p> <h3 className="font-medium mt-2">关键要点:</h3> <ul className="list-disc pl-5 space-y-1"> {result.keyPoints.map((point, i) => ( <li key={i} className="text-green-700">{point}</li> ))} </ul> </div> )} {error && ( <div className="bg-red-50 border-l-4 border-red-500 p-4"> <p className="text-red-700">处理失败:{error.message}</p> </div> )} </div> ); }这个组件体现了 Paperclip 的三大实操优势:
- 上传与执行解耦:
/api/upload是 Paperclip 内置的文件上传端点,它自动将文件保存到/tmp/uploads/并返回安全路径,前端无需关心 multer 配置或存储位置。这省去了 80% 的文件处理 boilerplate。 - UPlot 实时驱动:通过
useEffect监听logs,将结构化日志中的progress字段映射到图表坐标,实现了比传统 loading bar 更精细的反馈。用户能看到“已处理 327/1000 行”,而非模糊的“加载中”。 - 状态语义化:
status不是简单的'loading' | 'success',而是'idle' | 'running' | 'progressing' | 'completed' | 'failed' | 'cancelled'。这让你能写出更精准的 UI 逻辑,比如status === 'progressing'时显示进度条,status === 'cancelled'时显示“已取消,未产生费用”。
注意:
useTask的isPending状态与 React 的useTransition深度绑定。如果你在run()调用后立即console.log(status),很可能得到'idle',因为状态更新被startTransition延迟了。正确做法是监听status的变化,或使用useEffect响应其变更。
3.3 工具链整合:Vite + TypeScript + ESLint 的零配置实践
Paperclip 的模板项目(create-paperclip-app)默认采用 Vite 作为构建工具,这并非偶然。Vite 的 HMR(热模块替换)速度,对于频繁调试 Task handler 的开发者至关重要——你修改一行handler逻辑,保存后浏览器几乎瞬时刷新,而 Webpack 通常需要 2-3 秒。更重要的是,Vite 的defineConfig可以无缝集成 Paperclip 的代理需求:
// vite.config.ts import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], server: { port: 3000, proxy: { // 关键:将 /api/* 请求代理到本地 Node.js 服务 '/api': { target: 'http://localhost:3001', changeOrigin: true, // 必须!否则跨域 cookie 丢失 rewrite: (path) => path.replace(/^\/api/, ''), // 去掉 /api 前缀 }, }, }, // TypeScript 支持:Paperclip 的 .d.ts 声明文件已预置 resolve: { alias: { 'paperclip-react': 'paperclip-react/dist/index.mjs', 'paperclip-node': 'paperclip-node/dist/index.mjs', }, }, });changeOrigin: true是一个极易被忽略但致命的配置。当你的 React 应用运行在http://localhost:3000,而 Node.js 服务在http://localhost:3001,且两者都依赖同一套 Session Cookie 进行鉴权时,若不设changeOrigin,浏览器发送的请求Origin头仍是http://localhost:3000,Express 的cors中间件会因 origin 不匹配而拒绝,导致401 Unauthorized。Paperclip 的文档里没强调这点,但我在 Mac M2 上调试时,整整花了两小时排查这个 300 行配置里的单行缺失。
ESLint 配置同样体现 Paperclip 的务实哲学。它不引入@typescript-eslint/recommended-requiring-type-checking这类重型规则,而是聚焦于 AI 开发特有的风险点:
no-eval: 禁止在 Task handler 中使用eval(),防止 LLM 返回的恶意代码执行(Paperclip 的 JSON 解析器已做防护,但多一层保险)。no-console: 要求所有console.log替换为logger.info(),确保日志可集中收集(Paperclip 的TaskContext.logger会自动打上 task ID 和 timestamp)。@typescript-eslint/no-explicit-any: 强制类型定义,因为useTask的result类型直接来自outputSchema,若用any,TypeScript 就失去了类型安全的意义。
4. 实操全流程:CentOS 7.9 服务器上的完整部署与调试
4.1 Node.js 环境准备:绕过 yum 的版本陷阱
CentOS 7.9 自带的 Node.js 版本是 6.x,早已 EOL。直接yum install nodejs会安装一个无法运行 Paperclip 的旧版。正确姿势是使用 NodeSource 仓库:
# 1. 清理旧版本 sudo yum remove nodejs npm # 2. 添加 NodeSource 仓库(针对 18.x LTS) curl -fsSL https://rpm.nodesource.com/setup_lts.x | sudo bash # 3. 安装 Node.js 18.20.4 sudo yum install -y nodejs # 4. 验证版本(必须显示 v18.20.4) node --version # v18.20.4 npm --version # 9.9.2(随 Node.js 18.20.4 自带) # 5. 设置 npm 全局安装路径(避免权限问题) mkdir -p ~/.npm-global npm config set prefix '~/.npm-global' echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc source ~/.bashrc关键点在于setup_lts.x脚本。它会自动检测系统架构(x86_64)并配置正确的 RPM 仓库。我曾试过setup_20.x,结果安装了 Node.js 20,但 Paperclip 的某些底层依赖(如undici)在 20.12+ 版本有 breaking change,导致 SSE 连接异常断开。18.20.4 是经过 Paperclip 官方 CI 验证的黄金版本。
4.2 Paperclip 项目初始化与依赖安装
使用官方 CLI 创建项目:
# 全局安装 CLI(注意:不要用 sudo,用前面设置的 ~/.npm-global) npm install -g create-paperclip-app # 创建项目(选择 full-stack 模板) create-paperclip-app my-ai-app --template full-stack cd my-ai-app # 安装依赖(推荐用 npm ci,而非 npm install,确保 lockfile 一致性) npm cinpm ci是 Paperclip 部署的黄金准则。它会严格按照package-lock.json安装依赖,跳过package.json的版本范围解析,避免因^1.2.0解析到1.3.0而引入不兼容更新。我在一次紧急上线中,因误用npm install,导致ws库升级到 8.16.0,其close方法签名变更,使useTask的取消功能失效,用户点击取消后任务仍在后台运行。
4.3 后端服务启动与端口放行
Paperclip 的后端默认监听3001端口。在 CentOS 7.9 上,需配置防火墙:
# 开放 3001 端口 sudo firewall-cmd --permanent --add-port=3001/tcp sudo firewall-cmd --reload # 启动后端(使用 pm2 管理进程,确保崩溃后自动重启) npm install -g pm2 pm2 start server/index.ts --name "paperclip-backend" --watch # 查看进程状态 pm2 show paperclip-backend--watch参数是关键。它让 pm2 监控server/目录下的文件变更,一旦你修改了summaryTask,pm2 会自动重启进程,无需手动pm2 restart。这极大提升了迭代效率。
4.4 前端构建与 Nginx 反向代理配置
Paperclip 前端构建产物是静态文件,需通过 Nginx 提供服务,并代理 API 请求:
# /etc/nginx/conf.d/paperclip.conf upstream paperclip_backend { server 127.0.0.1:3001; } server { listen 80; server_name ai.yourcompany.com; # 前端静态文件 location / { root /var/www/my-ai-app/dist; try_files $uri $uri/ /index.html; } # API 代理:所有 /api/* 请求转发到 backend location /api/ { proxy_pass http://paperclip_backend/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; } # 上传文件大小限制(Paperclip 默认 50MB) client_max_body_size 50M; }proxy_pass末尾的/至关重要。它表示“去掉匹配的/api/前缀再转发”,否则请求会变成http://127.0.0.1:3001/api/task/xxx,而后端期望的是/task/xxx。这个/的有无,是 Nginx 代理配置中最常见的错误之一。
4.5 调试技巧:如何定位“任务卡死”问题
当用户报告“点击摘要按钮后,UI 一直 loading,但后台没日志”,按以下顺序排查:
检查浏览器 Network 面板:看
/api/task/document-summary请求是否发出、状态码是否为200、Response 是否为空。若请求根本没发出,问题在前端useTask调用逻辑。查看 pm2 日志:
pm2 logs paperclip-backend。若日志停留在Starting summary for ...,说明handler卡在某处。此时,在handler开头加logger.info('Step 1: read file'),结尾加logger.info('Step 2: got LLM response'),定位卡点。检查 Ollama 服务:Paperclip 默认调用
http://localhost:11434。在服务器上执行curl http://localhost:11434/api/tags,确认 Ollama 正在运行且模型已拉取。若返回Connection refused,需systemctl start ollama。验证 Signal 传递:在
handler中加入signal.addEventListener('abort', () => logger.warn('Task aborted!'))。然后在前端快速点击“取消”按钮,看日志是否出现Task aborted!。若没有,说明signal未正确传递,检查fetch调用是否遗漏signal参数。内存泄漏检测:若任务执行后,`pm2