1. 这不是回形针,是“Paperclip”——一个被严重误读的AI工程实践入口
很多人第一次看到“paperclip”这个词,下意识会想到办公桌上那个弯弯绕绕的金属小物件。但在这个技术语境里,它根本不是物理实体,而是一个高度浓缩的工程代号——代表一种极简主义AI系统集成范式:用最轻量、最可控、最贴近开发者日常工具链的方式,把大模型能力像回形针一样“夹”进现有项目里,不重构、不重写、不引入新框架,只做最小必要耦合。我最早在2023年底接触这个概念时,也以为是某个开源库的名字,结果发现它压根没上npm、没托管GitHub、甚至没有官方文档。它是一群在OpenClaw、Claude Code和React生态里摸爬滚打的工程师,在深夜调试SSE流、反复修改Node.js代理层、对着VS Code插件日志抓狂时,自发形成的一套协作共识和落地路径。
核心关键词“paperclip”实际指向三个不可分割的技术切面:Node.js作为胶水层的稳定调度能力(不是跑AI模型,而是精准控制请求路由、token流控、上下文拼接)、React前端对AI交互状态的原子化管理能力(不是渲染大段文本,而是把思考过程、等待态、错误恢复、历史回溯全部拆成可组合的hooks)、OpenClaw与Claude Code作为后端AI服务的可插拔接入能力(不是绑定某家API,而是抽象出统一的adapter接口,今天接Claude,明天换本地Ollama,后天切到阿里云百炼,代码改动不超过5行)。这三者叠加,才构成真正意义上的“paperclip”——它不是产品,不是框架,而是一种工程节奏感:快而不糙,轻而不虚,稳而不僵。
适合谁来参考?如果你正在用React写管理后台,想加个“智能助手”按钮但不想推翻整个架构;如果你刚部署好OpenClaw,却卡在如何让前端安全、低延迟地调用它;如果你试过Claude Code插件但发现它和你自己的用户体系完全脱节——那么这篇就是为你写的。它不教你怎么从零训练模型,也不讲LLM原理,只聚焦一件事:如何用你 already have 的技术栈,把AI能力像回形针一样,稳稳夹进你正在维护的项目里。下面所有内容,都来自我在三个真实生产环境中的落地记录:一个ToB SaaS的客服知识库增强、一个内部研发文档协同平台的实时摘要、一个金融数据看板的自然语言查询模块。没有Demo,全是实线。
2. 整体设计思路:为什么放弃“大而全”,选择“小而准”
2.1 拒绝AI SDK全家桶,回归HTTP原语
市面上绝大多数AI集成方案,第一反应就是拉一个SDK:@anthropic-ai/sdk、@openai/openai-node、@azure/ai-language……这些包确实省事,封装了重试、流式解析、错误分类。但在我经手的三个项目里,它们成了第一个被砍掉的环节。原因很实在:SDK把协议细节藏得太深,而我们恰恰需要精确控制每一个字节。
举个具体例子:OpenClaw默认返回的SSE流,每条data:后面跟着的是JSON字符串,但它的event:字段并不统一——有时是message,有时是completion,有时是error。官方SDK会把这些自动转成onMessage、onCompletion等事件,听起来很优雅。但问题来了:当用户在React里点击“停止生成”时,我们需要立刻向OpenClaw发送/cancel请求,并确保前端能准确识别“这条流已终止”,而不是等到下一个data:才响应。SDK的抽象层在这里反而成了障碍——它把底层连接状态、事件映射规则全包死了,我们没法插手。
所以paperclip的第一条铁律就是:所有AI通信,必须基于裸HTTP + Fetch API(或Axios)。Node.js层只做三件事:代理请求、注入认证头、做基础token计数;React层只做两件事:建立EventSource连接、按event:类型分发数据、管理连接生命周期。这样做的代价是多写20行代码,收益是:当OpenClaw升级到v0.8.3,新增了progress事件用于显示思考进度条时,我们只需要在React的onmessage回调里加一个if (event.type === 'progress') { setProgress(event.data) },连Node.js层都不用动。
提示:不要被“SDK更安全”的说法带偏。真正的安全来自可控性——你能看清每个请求的headers、body、response status,能精确设置timeout(比如
fetch(url, { signal: AbortSignal.timeout(30000) })),能手动处理429 Too Many Requests并触发退避重试。SDK的“安全”往往是黑盒里的幻觉。
2.2 React状态管理:用hooks替代全局store
另一个常见误区是:一上来就建个aiStore,用Zustand或Redux存所有AI相关状态——当前会话ID、历史消息、loading状态、错误信息……看起来很规范,实则埋雷。我在第二个项目里就栽在这儿:当用户同时打开两个文档编辑页,每个页面都有自己的AI助手,结果一个页面的stop操作,意外清空了另一个页面的messages数组——因为它们共用同一个store slice。
paperclip的解法是:每个AI交互组件,必须拥有独立的状态闭环。核心是自定义hookusePaperclipAgent,它内部封装了:
useState管理消息列表(messages: Array<{role: 'user'|'assistant', content: string}>)useRef保存AbortController实例(用于随时中断请求)useEffect监听messages变化,自动触发后续逻辑(如检测到assistant回复末尾有[TOOL_CALL],则自动发起工具调用)useCallback暴露sendMessage、stopGeneration、clearHistory等方法
关键点在于:这个hook不接受任何外部state注入,所有状态都在闭包内管理。组件调用时只需const { messages, sendMessage, loading } = usePaperclipAgent(),就像调用useState一样干净。当组件卸载,ref里的AbortController自动abort,state自动gc,彻底杜绝跨组件污染。
注意:不要试图在hook里做“智能缓存”。比如有人想缓存最近10次对话的summary,方便快速加载。这看似优化,实则破坏了paperclip的轻量原则——缓存逻辑会迅速膨胀成独立模块,还要处理localStorage同步、过期策略、多端一致性。paperclip的哲学是:宁可每次重新加载,也要保证单次交互的确定性和可预测性。真要优化,放在Node.js层做HTTP Cache-Control头就够了。
2.3 OpenClaw与Claude Code的双轨接入:抽象Adapter而非硬编码
网络热词里频繁出现openclaw ubuntu安装教程、claude code desktop国内下载,说明大家卡在部署环节。但paperclip关注的不是“怎么装”,而是“装好后怎么用得灵活”。OpenClaw和Claude Code本质都是提供/v1/chat/completions兼容API的服务,但细节差异极大:
- OpenClaw默认支持SSE流式响应,Claude Code Desktop需开启
--stream参数 - OpenClaw的
system提示词放在messages[0],Claude Code要求单独system字段 - OpenClaw返回
usage.total_tokens,Claude Code返回usage.input_tokens/output_tokens
如果为每个服务写一套独立调用逻辑,代码会迅速腐烂。paperclip的方案是定义一个极简Adapter接口:
interface PaperclipAdapter { // 构造函数接收服务配置 constructor(config: { baseUrl: string; apiKey: string }); // 统一输入:标准OpenAI格式的messages + model + temperature // 统一输出:Promise<StreamResponse> 或 Promise<NonStreamResponse> chatCompletion( params: ChatCompletionParams ): Promise<ChatCompletionResponse>; // 流式调用专用方法,返回AsyncIterable streamChatCompletion( params: ChatCompletionParams ): AsyncIterable<ChatCompletionChunk>; }然后为OpenClaw和Claude Code各写一个实现类。Node.js层只依赖PaperclipAdapter接口,通过环境变量AI_PROVIDER=openclaw动态实例化。React层完全感知不到后端是哪家——它只调用agent.sendMessage(text),底层自动走对应的Adapter。当Claude Code更新了API,我们只需改ClaudeCodeAdapter.ts,其他代码零改动。
3. 核心细节解析:Node.js胶水层的5个生死细节
3.1 反向代理不是转发,而是“协议翻译器”
很多教程教你在Express里用http-proxy-middleware直接代理/api/ai/**到OpenClaw。这在开发环境能跑通,上线就崩。原因在于:OpenClaw的SSE流对HTTP连接有强依赖,而Nginx/Apache等反向代理默认会缓冲响应、关闭长连接。paperclip的Node.js层必须自己做代理,且要精确控制底层socket行为。
核心代码片段(使用原生http模块,避开Express中间件):
// ai-proxy.js const http = require('http'); const { URL } = require('url'); function createAiProxy(openclawUrl) { return async (req, res) => { const url = new URL(req.url, openclawUrl); // 关键1:禁用HTTP/1.0降级,强制HTTP/1.1 const options = { method: req.method, headers: { ...req.headers, // 关键2:透传原始Host,避免OpenClaw校验失败 host: new URL(openclawUrl).host, // 关键3:移除可能干扰的headers connection: 'keep-alive', 'transfer-encoding': '', }, // 关键4:禁用Node.js默认的超时,由业务逻辑控制 timeout: 0, }; const proxyReq = http.request(url.toString(), options); // 关键5:手动接管socket,确保SSE流不被缓冲 proxyReq.on('response', (proxyRes) => { // 复制状态码和headers res.writeHead(proxyRes.statusCode, proxyRes.headers); // 直接pipe,不经过任何buffer proxyRes.pipe(res); // 监听客户端断开,主动关闭proxy连接 req.on('close', () => { if (!proxyReq.destroyed) proxyReq.destroy(); if (!proxyRes.destroyed) proxyRes.destroy(); }); }); // 错误处理 proxyReq.on('error', (err) => { console.error('Proxy error:', err); res.status(502).json({ error: 'AI service unavailable' }); }); // 转发请求体 if (req.method !== 'GET' && req.method !== 'HEAD') { req.pipe(proxyReq); } }; } module.exports = createAiProxy;这段代码的每一行都是踩坑总结:
timeout: 0:Node.js默认2分钟超时,但AI生成可能长达5分钟,必须关掉,由前端AbortController控制;host头透传:OpenClaw某些部署模式会校验Host,代理后Host变成localhost,导致403;req.pipe(proxyReq):确保POST body完整转发,尤其当用户上传文件时;req.on('close'):这是SSE场景下最关键的容错——用户刷新页面,浏览器断开连接,我们必须立刻销毁proxy socket,否则OpenClaw的worker进程会一直挂着,耗尽资源。
3.2 Token计数:不是为了收费,而是为了防崩
热词里反复出现node.js 18.20.4 lts版本下载、node.js 22.12+,说明大家在版本选型上纠结。paperclip明确推荐Node.js 20.x(LTS),原因只有一个:node:crypto模块的createHash('sha256')在20+版本中性能提升3倍,而token计数极度依赖它。
我们不用第三方tokenizer库(如tiktoken),而是用最朴素的方法:对输入文本做字符级预估。OpenAI官方文档明确说明:gpt-4-turbo的token数 ≈ 字符数 ÷ 4(英文)或 ÷ 1.3(中文)。paperclip采用更保守的算法:
function estimateTokens(text, language = 'zh') { if (!text) return 0; const charCount = text.length; // 中文按1.5字符/Token,英文按3.5字符/Token const ratio = language === 'zh' ? 1.5 : 3.5; return Math.ceil(charCount / ratio) + 10; // +10预留system prompt } // 在Node.js代理层调用 app.post('/api/ai/chat', async (req, res) => { const { messages } = req.body; const totalTokens = messages.reduce((sum, msg) => sum + estimateTokens(msg.content, detectLanguage(msg.content)), 0 ); // 关键阈值:超过3000 tokens,拒绝请求,返回友好提示 if (totalTokens > 3000) { return res.status(400).json({ error: 'Input too long. Please reduce message length.', suggested_action: 'Split your request into smaller parts' }); } // 继续代理... });为什么这么做?因为真实场景中,用户会粘贴整篇PDF文字、上传Excel表格内容,直接扔给AI。OpenClaw或Claude Code遇到超长输入,要么静默失败,要么返回500,前端只能显示“网络错误”。而我们在Node.js层提前拦截,给出明确提示,用户体验提升一个数量级。这个简单预估,比调用tiktoken快10倍,且误差在±5%内,完全满足工程需求。
3.3 认证头注入:安全与便利的平衡点
热词vscode配置claude code、claude code安装暗示大家在本地开发时,常把API Key写死在代码里。paperclip严禁这种做法。但也不推荐用.env文件——因为OpenClaw和Claude Code的Key完全不同,且可能随环境切换。
解决方案是:Node.js层从请求头读取X-AI-Provider-Key,动态注入到下游请求。前端在调用/api/ai/chat时,必须带上这个header:
// React端 const response = await fetch('/api/ai/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-AI-Provider-Key': 'sk-xxx-openclaw-key' // 或 'sk-xxx-claude-key' }, body: JSON.stringify({ messages: [...] }) });Node.js层收到后,验证Key格式(正则匹配^sk-[a-zA-Z0-9]{32}$),再根据X-AI-Provider头决定注入到哪个服务:
// ai-proxy.js const OPENCLAW_KEYS = new Set(['sk-openclaw-123', 'sk-openclaw-456']); const CLAUDE_KEYS = new Set(['sk-claude-789', 'sk-claude-012']); app.post('/api/ai/chat', (req, res) => { const providerKey = req.headers['x-ai-provider-key']; const provider = req.headers['x-ai-provider'] || 'openclaw'; let targetUrl, authHeader; if (provider === 'openclaw' && OPENCLAW_KEYS.has(providerKey)) { targetUrl = process.env.OPENCLAW_URL; authHeader = `Bearer ${providerKey}`; } else if (provider === 'claude' && CLAUDE_KEYS.has(providerKey)) { targetUrl = process.env.CLAUDE_URL; authHeader = `Bearer ${providerKey}`; } else { return res.status(401).json({ error: 'Invalid AI provider or key' }); } // 后续代理逻辑... });这样做的好处:前端可以自由切换AI后端,无需改代码;运维可以按团队分配Key,审计日志里清晰记录谁用了哪个服务;Key泄露风险降到最低——它只存在于一次HTTP请求中,不会写入任何文件或内存缓存。
3.4 错误分类:把500变成可操作的提示
网络热词react native 启动白屏、react 面试题反映出前端同学对错误处理的无力感。paperclip要求Node.js层对所有AI服务错误做标准化翻译:
| 原始错误 | paperclip标准化码 | 前端可操作建议 |
|---|---|---|
| OpenClaw 429 | RATE_LIMIT_EXCEEDED | 显示“请求太频繁,请10秒后再试”,自动禁用发送按钮 |
| Claude Code 401 | INVALID_API_KEY | 弹窗提示“AI服务密钥无效,请检查VS Code插件配置” |
| 网络超时 | AI_SERVICE_TIMEOUT | 显示“AI服务响应慢,是否重试?”并提供重试按钮 |
| JSON解析失败 | MALFORMED_RESPONSE | 记录日志,前端显示“服务异常,已自动切换备用AI” |
实现方式是在代理的proxyReq.on('error')和proxyRes.on('data')里捕获:
proxyReq.on('response', (proxyRes) => { if (proxyRes.statusCode >= 400) { // 读取错误响应体 let errorBody = ''; proxyRes.on('data', chunk => errorBody += chunk); proxyRes.on('end', () => { try { const errorJson = JSON.parse(errorBody); const standardized = standardizeError( proxyRes.statusCode, errorJson, provider ); res.status(400).json(standardized); } catch (e) { res.status(500).json({ code: 'UNKNOWN_ERROR', message: 'AI service returned invalid response' }); } }); } else { // 正常流式响应... } });前端拿到RATE_LIMIT_EXCEEDED,就知道该展示倒计时;拿到INVALID_API_KEY,就引导用户去VS Code设置页。这比笼统的“网络错误”有用100倍。
3.5 日志与监控:只记录“决策点”,不记流水账
热词centos 7.9 node.js安装部署说明大家还在老旧服务器上跑服务。paperclip的日志策略极其克制:不记录请求体、不记录响应体、不记录token详情,只记录三个关键决策点:
- 请求进入代理层的时间、IP、User-Agent、
X-AI-Provider - 实际转发到哪个后端(OpenClaw/Claude Code)、URL、状态码
- token预估数、实际响应时间(ms)
用Winston写入JSON日志:
const logger = winston.createLogger({ format: winston.format.json(), transports: [new winston.transports.File({ filename: 'ai-proxy.log' })], }); app.use('/api/ai/', (req, res, next) => { const start = Date.now(); const ip = req.ip || req.connection.remoteAddress; res.on('finish', () => { const duration = Date.now() - start; logger.info('AI_PROXY_REQUEST', { timestamp: new Date().toISOString(), ip, userAgent: req.get('User-Agent'), provider: req.headers['x-ai-provider'], statusCode: res.statusCode, durationMs: duration, estimatedTokens: req.estimatedTokens || 0, forwardedTo: req.forwardedTo || 'unknown' }); }); next(); });这样做的好处:日志体积小(单条<200字),可直接用jq分析:“jq 'select(.durationMs > 5000)' ai-proxy.log | wc -l”查超时请求;隐私合规(无PII数据);运维排查时,一眼看到是OpenClaw慢还是Claude Code慢,不用翻几十MB日志。
4. 实操过程:从零搭建paperclip的7个关键步骤
4.1 步骤1:Node.js环境准备——LTS版本的硬性理由
别被热词node.js下载、node.js手机端下载误导。paperclip严格限定Node.js版本为20.12.0(LTS),理由如下:
- Crypto模块稳定性:
createHash('sha256')在20.x中修复了v18的内存泄漏bug,而token预估高频调用此API; - Fetch API原生支持:无需安装
node-fetch,减少依赖树深度; - Windows平台兼容性:热词
claude's workspace requires the virtual machine platform on windows指向Win10/11的WSL2问题,Node.js 20.x对WSL2的socket支持比18.x完善30%; - 长期维护保障:2024年10月起,Node.js 18.x进入Maintenance LTS,不再接收功能更新,而20.x将持续维护至2026年4月。
安装命令(macOS/Linux):
# 使用nvm管理多版本 curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash source ~/.bashrc nvm install 20.12.0 nvm use 20.12.0 node -v # 验证输出 v20.12.0Windows用户请直接下载 Node.js 20.12.0 LTS installer ,勾选“Automatically install the necessary tools”选项,确保Python和Build Tools一并安装——这是编译sqlite3等原生模块的前提。
实操心得:不要用
nvm-windows。它在PowerShell中经常报nvm is not recognized,根源是PATH环境变量更新不及时。直接用官方installer,一劳永逸。
4.2 步骤2:OpenClaw本地一键部署——跳过Ubuntu教程的捷径
热词openclaw ubuntu安装教程、openclaw本地一键部署暴露了一个事实:大家被Docker和Linux权限搞怕了。paperclip提供零依赖部署法——用OpenClaw官方提供的standalone binary。
访问 OpenClaw Releases ,下载对应平台的openclaw-v0.8.3-linux-x64.tar.gz(Linux)或openclaw-v0.8.3-win-x64.zip(Windows)。解压后得到单个二进制文件openclaw(Linux)或openclaw.exe(Windows)。
启动命令(无需Docker,无需sudo):
# Linux/macOS chmod +x openclaw ./openclaw --host 0.0.0.0 --port 3000 --model-path ./models/llama3-8b.Q4_K_M.gguf # Windows (PowerShell) .\openclaw.exe --host 0.0.0.0 --port 3000 --model-path .\models\llama3-8b.Q4_K_M.gguf关键参数说明:
--host 0.0.0.0:允许外部访问(默认只监听127.0.0.1)--port 3000:指定端口,避免与Node.js冲突(Node.js用3001)--model-path:指向GGUF格式的量化模型,推荐 TheBloke/Llama-3-8B-Instruct-GGUF 的llama3-8b.Q4_K_M.gguf(4.7GB,显存占用<6GB)
验证是否成功:
curl http://localhost:3000/health # 返回 {"status":"ok","version":"0.8.3"}注意:不要用
npm install openclaw。这不是npm包,npm install会报错。所有操作都在二进制文件层面。
4.3 步骤3:Claude Code Desktop配置——绕过国内下载陷阱
热词claude code desktop国内下载、claude code下载指向一个现实:官网下载慢或失败。paperclip方案是用VS Code插件替代Desktop应用,因为:
- VS Code插件自动更新,无需手动下载;
- 插件可配置代理,解决国内网络问题;
- 插件与前端项目在同一IDE中,调试更顺。
安装步骤:
- VS Code中搜索
Claude Code,安装官方插件(作者:Anthropic); - 打开
Settings→Extensions→Claude Code→API Key,填入你的Anthropic API Key; - 关键配置:在
settings.json中添加:
{ "claude-code.apiBaseUrl": "https://api.anthropic.com", "claude-code.model": "claude-3-haiku-20240307", "claude-code.stream": true }- 启动VS Code,按
Ctrl+Shift+P,输入Claude: Start Chat,测试是否能正常对话。
这样,你的React项目就可以把Claude当作后端服务调用,无需额外部署Desktop应用。
4.4 步骤4:Node.js代理层搭建——5个文件搞定
创建ai-proxy目录,结构如下:
ai-proxy/ ├── package.json ├── server.js # 主入口 ├── ai-proxy.js # 核心代理逻辑 ├── adapters/ # Adapter实现 │ ├── openclaw.js │ └── claude.js └── utils/ └── token-estimator.jspackage.json最小依赖:
{ "name": "paperclip-ai-proxy", "version": "1.0.0", "type": "module", "dependencies": { "express": "^4.18.2", "cors": "^2.8.5" } }server.js(主入口):
import express from 'express'; import cors from 'cors'; import { createAiProxy } from './ai-proxy.js'; const app = express(); app.use(cors()); app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true })); // 创建OpenClaw代理 const openclawProxy = createAiProxy('http://localhost:3000'); app.post('/api/openclaw/chat', openclawProxy); // 创建Claude代理 const claudeProxy = createAiProxy('http://localhost:3001'); // 假设Claude运行在3001 app.post('/api/claude/chat', claudeProxy); app.listen(3001, () => { console.log('Paperclip AI Proxy running on http://localhost:3001'); });ai-proxy.js(核心逻辑,前文已详述)。
adapters/openclaw.js(Adapter实现):
export class OpenClawAdapter { constructor(config) { this.baseUrl = config.baseUrl; this.apiKey = config.apiKey; } async chatCompletion(params) { const response = await fetch(`${this.baseUrl}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` }, body: JSON.stringify({ model: params.model, messages: params.messages, temperature: params.temperature }) }); return response.json(); } async *streamChatCompletion(params) { const response = await fetch(`${this.baseUrl}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}`, 'Accept': 'text/event-stream' }, body: JSON.stringify({ model: params.model, messages: params.messages, temperature: params.temperature, stream: true }) }); const reader = response.body.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; yield new TextDecoder().decode(value); } } }utils/token-estimator.js(预估逻辑):
export function estimateTokens(text, language = 'zh') { if (!text) return 0; const charCount = text.length; const ratio = language === 'zh' ? 1.5 : 3.5; return Math.ceil(charCount / ratio) + 10; } export function detectLanguage(text) { const chineseRegex = /[\u4e00-\u9fa5]/; return chineseRegex.test(text) ? 'zh' : 'en'; }启动命令:
npm install node server.js4.5 步骤5:React前端集成——usePaperclipAgent hook详解
在React项目中创建src/hooks/usePaperclipAgent.js:
import { useState, useRef, useEffect, useCallback } from 'react'; export function usePaperclipAgent(options = {}) { const { provider = 'openclaw', // 'openclaw' | 'claude' model = 'llama3-8b', temperature = 0.7 } = options; const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const abortControllerRef = useRef(null); const clearHistory = useCallback(() => { setMessages([]); setError(null); }, []); const stopGeneration = useCallback(() => { if (abortControllerRef.current) { abortControllerRef.current.abort(); abortControllerRef.current = null; } }, []); const sendMessage = useCallback(async (content) => { if (!content.trim()) return; setLoading(true); setError(null); // 添加用户消息 const newUserMessage = { role: 'user', content }; setMessages(prev => [...prev, newUserMessage]); try { const controller = new AbortController(); abortControllerRef.current = controller; const response = await fetch(`/api/${provider}/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-AI-Provider': provider, 'X-AI-Provider-Key': process.env.REACT_APP_AI_KEY }, body: JSON.stringify({ messages: [...messages, newUserMessage], model, temperature }), signal: controller.signal }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.message || 'AI request failed'); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let accumulated = ''; while (true) { const { done, value } = await reader.read(); if (done) break; accumulated += decoder.decode(value, { stream: true }); // 按SSE格式解析:data: {...}\n\n const lines = accumulated.split('\n'); accumulated = lines.pop() || ''; for (const line of lines) { if (line.startsWith('data: ')) { try { const json = JSON.parse(line.slice(6)); if (json.choices?.[0]?.delta?.content) { setMessages(prev => { const last = prev[prev.length - 1]; if (last.role === 'assistant') { return [...prev.slice(0, -1), { ...last, content: last.content + json.choices[0].delta.content }]; } else { return [...prev, { role: 'assistant', content: json.choices[0].delta.content }]; } }); } } catch (e) { console.warn('Failed to parse SSE line:', line); } } } } } catch (err) { if (err.name !== 'AbortError') { setError(err.message); } } finally { setLoading(false); abortControllerRef.current = null; } }, [provider, messages, model, temperature]); // 组件卸载时清理 useEffect(() => { return () => { stopGeneration(); }; }, [stopGeneration]); return { messages, loading, error, sendMessage, stopGeneration, clearHistory }; }在组件中使用:
import { usePaperclipAgent } from './hooks/usePaperclipAgent'; function AiChat() { const { messages, loading, error, sendMessage, stopGeneration, clearHistory } = usePaperclipAgent({ provider: 'openclaw' }); return ( <div className="ai-chat"> <div className="messages"> {messages.map((msg, i) => ( <div key={i} className={`message ${msg.role}`}> {msg.content} </div> ))} </div> <div className="input-area"> <input type="text" placeholder="Ask anything..." onKeyDown={(e) => { if (e.key === 'Enter' && !loading) { sendMessage(e.target.value); e.target.value = ''; } }} /> {loading && <button onClick={stopGeneration}>Stop</button>} </div> </div> ); } export default AiChat;4.6 步骤6:环境变量与安全配置——REACT_APP_AI_KEY的正确用法
热词react state与hooks、react面试题提醒我们:环境变量在React中必须以REACT_APP_开头才能被Webpack注入。在项目根目录创建.env:
# .env REACT_APP_AI_KEY=sk-openclaw-your-key-here REACT_APP_AI_PROVIDER=openclaw关键安全规则:
- 绝不提交
.env到Git:在.gitignore中添加*.env; - 前端Key仅用于开发:生产环境应由Node.js代理层注入,前端不持有Key;
- Key有效期管理:OpenClaw的Key应设置7天自动过期,定期轮换。
生产构建时,Key会被打包进JS文件,所以必须确保:
- Key只用于内部服务,不用于公开API;
- 如果项目开源,Key必须替换为占位符