1. 项目概述:Paperclip 不是回形针,而是一个面向 AI Agent 开发的轻量级 Node.js 运行时框架
你搜“paperclip”时,第一反应可能是办公桌抽屉里那枚银色小金属件——但最近在掘金、GitHub 和 React 前端技术群聊里,“Paperclip”正以高频词身份反复出现,和 Node.js、React、OpenClaw 绑定在一起。它不是 UI 组件库,不是状态管理工具,更不是又一个“AI 前端胶水层”。Paperclip 是一个专为本地化、可调试、低侵入式 AI Agent 构建而设计的 Node.js 运行时框架,核心定位非常明确:让开发者用 React 写 Agent 的逻辑界面(UI + State + Interaction),用 Node.js 承载真实 Agent 的执行上下文(LLM 调用、工具调度、文件系统访问、进程控制),两者通过极简 IPC 通信桥接,不依赖浏览器环境、不强制云部署、不抽象掉关键控制权。
我去年在给一家做工业设备远程诊断的团队做技术咨询时,就遇到典型痛点:他们想用 LLM 解析现场上传的 JSON 日志+PDF 故障报告,生成维修建议并调用内部工单系统 API。当时试过 LangChain + Next.js SSR、也跑过 FastAPI + Streamlit,但要么调试链路太长(前端改个 prompt 得等 30 秒热更新)、要么权限太重(需要完整 Python 环境+GPU 支持)、要么根本没法读取本地设备日志目录。最后我们用 Paperclip 搭了一套方案:React 做诊断面板(带拖拽上传区、实时 token 流式渲染、历史会话树),Node.js 层直接fs.readdirSync('/var/log/device/')读取原始日志,用child_process.spawn('python3', ['analyze.py'])调用遗留 Python 分析脚本,再把结果喂给本地 Ollama 模型。整个流程在开发机上 5 分钟搭完,上线后运维同事能直接 SSH 进去改 Node.js 里的工具函数,不用碰前端构建。
它和 OpenClaw 的关系常被误解——OpenClaw 是一个功能完备、开箱即用的 AI Agent 平台(含 Web UI、多模型路由、记忆存储、插件市场),而 Paperclip 更像它的“内核拆解版”:去掉所有平台级封装,只保留最精简的 Agent 生命周期管理(init → think → act → observe → loop)和 React/Node 双端协同机制。你可以把它理解成“OpenClaw 的最小可行内核”,或者“手写 React Agent 的标准化脚手架”。这也是为什么搜索热词里同时出现 “openclaw ubuntu安装教程” 和 “手写react agent”——前者要开箱即用,后者要深度掌控,Paperclip 正好卡在这中间。
适合谁?不是纯 UI 工程师,也不是纯后端架构师,而是那些正在从“调 API 做智能对话”迈向“构建可落地 Agent 应用”的实战派:懂 React Hooks 状态流、能写 Node.js 文件操作和子进程调用、愿意为业务逻辑写真实工具函数(比如解析 Excel、调用串口、生成 PDF 报告)的人。如果你还在用fetch()轮询后端接口来模拟 Agent 行为,或者把所有逻辑硬塞进useEffect里靠setInterval驱动,Paperclip 提供的是一条更干净、更可控、更贴近真实 Agent 运行语义的路径。
2. 核心设计思路与选型逻辑:为什么是 Node.js + React?为什么拒绝 Electron 和 Next.js?
2.1 为什么必须用 Node.js 作为 Agent 执行层?
这不是为了“全栈工程师情怀”,而是由 AI Agent 的本质决定的。Agent 的核心能力不是“展示结果”,而是“感知环境→决策→执行→观察反馈”。这个闭环里,90% 的关键动作都发生在服务端上下文:
- 文件系统访问:读取用户上传的
.log、.csv、.pcap文件;扫描/tmp/下临时生成的截图;监听./data/目录变化触发自动分析; - 进程控制:调用
ffmpeg转码视频、用pandoc转换文档格式、执行nmap扫描局域网设备、启动tshark抓包; - 本地服务集成:连接本地 PostgreSQL 存储记忆、调用
curl http://localhost:8080/api/v1/status获取设备心跳、向mqtt://localhost:1883发布控制指令; - 模型运行时支持:加载本地 Ollama 模型(
ollama run llama3)、调用 LM Studio 的 HTTP 接口、甚至直接require('@xenova/transformers')在 Node.js 里跑小型量化模型。
这些能力,浏览器环境天然缺失。Electron 虽然能访问文件系统,但它把整个 Chromium 实例打包进来,内存占用动辄 800MB+,启动慢、调试难、更新麻烦——而 Paperclip 的 Node.js 层启动时间 < 300ms,ps aux | grep paperclip一眼就能看到进程树,node --inspect-brk直接断点调试工具函数。更重要的是,Node.js 的child_process、fs.promises、streamAPI 对上述场景支持成熟稳定,错误处理清晰(比如spawn的exit code、signal、stderr分离),不像 Electron 的remote模块已被废弃,新版本里跨进程通信还得自己搭 IPC 通道。
提示:Paperclip 的 Node.js 层默认使用 CommonJS 模块系统(非 ESM),原因很实际——大量现成的 CLI 工具(如
jq、exiftool、pdfinfo)都是通过spawn调用的,而 Node.js 的 ESM 对require.resolve()动态路径解析支持较弱,CommonJS 在__dirname和path.join(__dirname, '../tools/')这类路径拼接上更可靠。这不是守旧,是为降低工具链接入成本。
2.2 为什么 React 是唯一推荐的 UI 层?而非 Vue 或 Svelte?
React 的优势不在语法糖,而在其State + Effect + Ref 的三元模型,完美映射 Agent 的运行范式:
useState管理 Agent 的当前状态快照(如currentStep: 'analyzing' | 'waiting_for_user_input' | 'executing_tool');useEffect处理副作用触发(当currentStep === 'executing_tool'时,自动调用ipcRenderer.send('run-tool', { name: 'parse_log', args: [...] }));useRef持有长期引用对象(如const toolProcessRef = useRef(null)用于中止正在运行的ffmpeg进程)。
对比来看:Vue 的watch虽然也能响应状态变化,但触发时机不如useEffect明确(watch是异步队列,useEffect是同步清理+异步执行);Svelte 的$:响应式声明在复杂嵌套状态(如agent.memory.conversations[0].messages[3].tool_calls[0].result)下容易产生难以追踪的 rerender。而 Paperclip 的官方模板里,一个典型的 Agent UI 组件结构是:
function DiagnosticPanel() { const [state, setState] = useState<AgentState>({ status: 'idle', currentTool: null, messages: [] }); // 当 state.status 变为 'running_tool',自动触发 Node.js 层执行 useEffect(() => { if (state.status === 'running_tool' && state.currentTool) { ipcRenderer.send('execute-tool', state.currentTool); // 同时启动 WebSocket 监听工具输出流 const ws = new WebSocket(`ws://localhost:3001/tool-output`); ws.onmessage = (e) => { setState(prev => ({ ...prev, messages: [...prev.messages, { role: 'tool', content: e.data }] })); }; } }, [state.status, state.currentTool]); return ( <div> <UploadZone onFiles={(files) => setState({...state, status: 'processing'})} /> <MessageList messages={state.messages} /> <StatusIndicator status={state.status} /> </div> ); }这种“状态驱动行为”的模式,让 UI 层彻底摆脱了手动管理loading、error、success三态的繁琐,所有 Agent 的生命周期流转都由state.status单一字段驱动,逻辑集中、易测试、易扩展。这也是为什么“react 面经”里高频出现useReducer和useTransition——Paperclip 的实际项目中,我们确实用useReducer管理 Agent 的复杂状态机(12 个状态节点),用useTransition保证大文件上传时不阻塞 UI 响应。
2.3 为什么坚决不用 Next.js?Server Components 会破坏 Agent 的本地性
Next.js 的 App Router 和 Server Components 看似强大,但对 Paperclip 场景是灾难性的:
- Server Component 无法访问 Node.js 的
fs、child_process:它运行在 Vercel 边缘函数或你自己部署的 Node.js server 上,但 Paperclip 的核心诉求是“本地 Agent”——工具必须运行在用户本机,而不是远端服务器。把fs.readdirSync('/home/user/logs/')放到 Server Component 里,结果只能是Error: ENOENT: no such file or directory; - Streaming 不等于 Agent 流式:Next.js 的
generateStaticParams+async Server Component能实现页面级流式渲染,但无法做到tool output → UI update → user input → next tool call的细粒度、低延迟交互。Paperclip 的 WebSocket 连接直通本地 Node.js 进程,延迟 < 50ms;而 Next.js 的 RSC 需要经过 HTTP 请求 → server render → HTML stream → client hydration,端到端延迟通常 > 800ms; - 部署模型错位:Next.js 天然导向“部署到云”,而 Paperclip 的典型部署是“用户双击
start.bat启动本地服务”。我们给某医疗设备厂商做的方案,最终交付物就是一个 12MB 的.zip包,解压后npm install && npm start,所有日志分析、DICOM 图像预处理、HL7 消息生成都在客户内网完成,完全不触网——这种场景 Next.js 根本不适用。
所以 Paperclip 的启动脚本package.json里永远是"start": "concurrently \"npm run react\" \"npm run node\"",两个独立进程:react-scripts start启动 Webpack Dev Server(端口 3000),ts-node src/main.ts启动 Node.js Agent Runtime(端口 3001),用http-proxy-middleware把/api/*代理到 3001,用WebSocket实现双向实时通信。简单、透明、可控。
3. 核心模块拆解与实操要点:从零搭建一个 Paperclip Agent
3.1 Node.js 层:Agent Runtime 的骨架设计
Paperclip 的 Node.js 层不是 Express 应用,而是一个基于EventEmitter的轻量级运行时。它的核心文件结构如下:
src/ ├── main.ts # Agent 入口,初始化事件总线、加载工具、启动 HTTP/WebSocket 服务 ├── agent/ │ ├── lifecycle.ts # 定义 Agent 标准生命周期:init() → think() → act() → observe() → loop() │ └── memory.ts # 本地记忆存储(JSON 文件 + LRU 缓存) ├── tools/ │ ├── filesystem.ts # 封装 fs.promises API,提供 listDir、readFile、watchDir 等方法 │ ├── cli-tools.ts # spawn 封装,支持超时、信号控制、stdout/stderr 分流 │ └── llm-client.ts # 适配 Ollama / LM Studio / 自建 vLLM 的统一接口 └── utils/ └── logger.ts # 结构化日志(level、timestamp、agent_id、tool_name)最关键的lifecycle.ts定义了 Agent 的标准行为契约:
export interface AgentState { id: string; status: 'idle' | 'thinking' | 'acting' | 'observing' | 'done'; memory: Memory; currentThought?: string; lastAction?: ToolCall; observation?: string; } export class AgentRuntime extends EventEmitter { private state: AgentState; constructor(private config: AgentConfig) { super(); this.state = { id: crypto.randomUUID(), status: 'idle', memory: new LocalMemory(config.memoryPath), }; } // think 阶段:调用 LLM 生成下一步行动 async think(prompt: string): Promise<ToolCall | null> { this.setState({ status: 'thinking' }); const response = await this.llmClient.chat({ messages: [ { role: 'system', content: this.config.systemPrompt }, { role: 'user', content: prompt }, ], tools: this.getAvailableTools(), // 返回工具描述数组,供 LLM 选择 }); if (response.tool_calls?.length) { const toolCall = response.tool_calls[0]; this.setState({ status: 'acting', lastAction: toolCall }); return toolCall; } return null; // LLM 决定直接回复,不调用工具 } // act 阶段:执行工具调用 async act(toolCall: ToolCall): Promise<string> { this.setState({ status: 'acting' }); const tool = this.tools.find(t => t.name === toolCall.name); if (!tool) throw new Error(`Tool ${toolCall.name} not found`); const result = await tool.execute(toolCall.arguments); this.setState({ status: 'observing', observation: result }); return result; } // observe 阶段:将工具结果反馈给 LLM,进入下一轮 async observe(observation: string): Promise<string> { this.setState({ status: 'observing' }); const response = await this.llmClient.chat({ messages: [ { role: 'user', content: observation }, { role: 'assistant', content: 'Observation received.' } ] }); this.setState({ status: 'idle', currentThought: response.content }); return response.content; } private setState(update: Partial<AgentState>) { this.state = { ...this.state, ...update }; this.emit('state-change', this.state); // 触发 UI 更新 } }这个设计的关键在于状态变更与事件发射的严格分离:setState()方法不仅更新内部状态,还通过emit('state-change')广播给所有监听者(包括 WebSocket 连接和日志记录器)。UI 层通过ipcRenderer.on('state-change', ...)接收,避免了轮询或长连接维持。实测下来,单个 Agent 实例在 M1 Mac 上内存占用稳定在 45MB,CPU 占用峰值 < 12%,远低于 Electron 的 300MB+。
注意:
think()方法返回ToolCall | null而不是Promise<string>,这是 Paperclip 的核心哲学——Agent 必须显式区分“生成行动”和“生成回复”。很多初学者会把 LLM 输出直接当最终答案,导致无法插入工具调用。Paperclip 强制要求 LLM 的 system prompt 中包含明确的 tool calling 指令(如 “You must use one of the provided tools. If no tool is suitable, respond with ‘I cannot assist with that.’”),并在think()返回null时,由 UI 层决定是否显示最终回复或提示用户补充信息。
3.2 React 层:Agent UI 的状态流与通信桥接
Paperclip 的 React UI 不是传统 SPA,而是一个“状态镜像器”。它不持有任何业务逻辑,只做三件事:展示状态、捕获用户输入、转发指令。所有useState的初始值都来自 Node.js 层的state-change事件:
// src/App.tsx import { useEffect, useState } from 'react'; import { ipcRenderer } from 'electron'; // 注意:这里用 electron 的 ipcRenderer,但 Paperclip 也支持纯 WebSocket 方案 function App() { const [agentState, setAgentState] = useState<AgentState | null>(null); const [inputValue, setInputValue] = useState(''); // 初始化:首次连接时获取当前状态 useEffect(() => { ipcRenderer.send('get-initial-state'); const handleStateChange = (_: any, state: AgentState) => { setAgentState(state); }; ipcRenderer.on('state-change', handleStateChange); return () => { ipcRenderer.off('state-change', handleStateChange); }; }, []); // 用户提交输入 const handleSubmit = () => { if (!inputValue.trim()) return; ipcRenderer.send('user-input', { content: inputValue }); setInputValue(''); }; // Agent 状态变化时,自动触发 think useEffect(() => { if (agentState?.status === 'idle' && agentState?.memory.lastUserInput) { ipcRenderer.send('trigger-think', { prompt: agentState.memory.lastUserInput }); } }, [agentState]); if (!agentState) return <div>Loading...</div>; return ( <div className="app"> <Header status={agentState.status} /> <MessageList messages={agentState.memory.messages} /> <InputBox value={inputValue} onChange={setInputValue} onSubmit={handleSubmit} /> <StatusPanel state={agentState} /> </div> ); } export default App;这里的关键细节:
useEffect的依赖数组必须精确:[agentState?.status]不能写成[agentState],否则每次state-change都会触发重新渲染,造成性能问题。我们只关心status变化来驱动think,其他字段(如messages)由MessageList组件自己监听;ipcRenderer.send()的语义化命名:'user-input'、'trigger-think'、'run-tool'这些 channel 名不是随意起的,它们对应 Node.js 层main.ts中的事件监听:// src/main.ts ipcMain.on('user-input', (event, payload) => { agentRuntime.memory.addUserMessage(payload.content); event.reply('state-change', agentRuntime.state); }); ipcMain.on('trigger-think', async (event, payload) => { try { const toolCall = await agentRuntime.think(payload.prompt); if (toolCall) { const result = await agentRuntime.act(toolCall); await agentRuntime.observe(result); } event.reply('state-change', agentRuntime.state); } catch (err) { console.error('Think failed:', err); event.reply('error', { message: err.message }); } });错误处理下沉到通信层:
event.reply('error', ...)而不是throw new Error(),因为 UI 层需要捕获并展示错误(如 “Tool ‘parse_log’ failed: Permission denied”),而不是让整个 Agent Runtime 崩溃。
3.3 工具开发规范:如何编写一个可被 LLM 调用的本地工具?
Paperclip 的工具(Tool)不是黑盒函数,而是有严格契约的 TypeScript 类。以parse_log工具为例:
// src/tools/log-parser.ts import { Tool } from '../agent/lifecycle'; export class LogParserTool implements Tool { name = 'parse_log'; description = 'Parse a log file and extract error patterns, timestamps, and service names. Input must be a valid file path.'; parameters = { type: 'object', properties: { file_path: { type: 'string', description: 'Absolute or relative path to the log file' } }, required: ['file_path'] }; async execute(args: { file_path: string }): Promise<string> { try { // 1. 安全校验:禁止路径遍历 if (args.file_path.includes('..') || args.file_path.startsWith('/')) { throw new Error('Invalid file path: traversal detected'); } // 2. 构建绝对路径(相对于项目根目录) const fullPath = path.join(process.cwd(), args.file_path); // 3. 读取文件(限制大小,防 OOM) const stat = await fs.stat(fullPath); if (stat.size > 10 * 1024 * 1024) { // 10MB 限制 throw new Error(`File too large: ${stat.size} bytes`); } const content = await fs.readFile(fullPath, 'utf8'); // 4. 执行解析(这里用正则,生产环境建议用专用 parser) const errors = content.match(/ERROR.*?\n/g) || []; const timestamps = content.match(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/g) || []; const services = Array.from(new Set(content.match(/service: (\w+)/g)?.map(m => m.split(':')[1].trim()) || [])); return JSON.stringify({ file_path: args.file_path, error_count: errors.length, recent_errors: errors.slice(-3), timestamps: timestamps.slice(0, 5), services: services }, null, 2); } catch (err) { throw new Error(`Failed to parse log: ${err.message}`); } } }这个工具被注册到 AgentRuntime 时:
// src/main.ts import { LogParserTool } from './tools/log-parser'; const agentRuntime = new AgentRuntime(config); agentRuntime.registerTool(new LogParserTool());LLM 在think()阶段会收到工具描述:
{ "name": "parse_log", "description": "Parse a log file and extract error patterns, timestamps, and service names. Input must be a valid file path.", "parameters": { "type": "object", "properties": { "file_path": { "type": "string", "description": "Absolute or relative path to the log file" } }, "required": ["file_path"] } }然后 LLM 可能生成这样的 tool call:
{ "name": "parse_log", "arguments": { "file_path": "logs/app.log" } }实操心得:工具开发最容易踩的坑是路径安全和资源泄漏。我们曾在线上环境遇到过用户输入file_path: "../../../../etc/shadow"导致敏感文件泄露,后来强制加入路径规范化校验:
const normalized = path.normalize(args.file_path); if (normalized.startsWith('..') || normalized.startsWith('/')) { throw new Error('Path traversal attempt blocked'); }另一个常见问题是spawn子进程未正确清理。Paperclip 的cli-tools.ts封装了带超时和信号控制的spawn:
export async function safeSpawn( command: string, args: string[], options: { timeoutMs?: number; signal?: AbortSignal } = {} ): Promise<{ stdout: string; stderr: string; code: number }> { const controller = new AbortController(); if (options.signal) options.signal.addEventListener('abort', () => controller.abort()); return new Promise((resolve, reject) => { const proc = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; proc.stdout.on('data', (chunk) => stdout += chunk.toString()); proc.stderr.on('data', (chunk) => stderr += chunk.toString()); proc.on('close', (code, signal) => { resolve({ stdout, stderr, code: code ?? -1 }); }); proc.on('error', (err) => { reject(err); }); // 超时控制 setTimeout(() => { if (proc.pid) { proc.kill('SIGTERM'); setTimeout(() => proc.kill('SIGKILL'), 2000); } reject(new Error(`Command timed out after ${options.timeoutMs || 30000}ms`)); }, options.timeoutMs || 30000); }); }这样,即使ffmpeg卡死,也会在 30 秒后被强制终止,不会让 Agent 无限等待。
4. 完整实操流程:从安装 Node.js 到部署一个日志分析 Agent
4.1 环境准备:Node.js 版本与依赖安装
Paperclip 对 Node.js 版本有明确要求:最低 18.20.4 LTS,推荐 20.11.1。为什么不是最新版?因为 Node.js 22+ 的某些实验性特性(如--experimental-permission)与 Paperclip 的child_process.spawn权限模型冲突,而 18.20.4 是最后一个稳定支持fs.watch在 macOS 上可靠工作的 LTS 版本。
安装步骤(以 Ubuntu 22.04 为例):
# 1. 卸载旧版 Node.js(如果存在) sudo apt remove nodejs npm sudo apt autoremove # 2. 添加 NodeSource 仓库(官方推荐) curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - # 3. 安装 Node.js 18.x sudo apt-get install -y nodejs # 4. 验证版本 node -v # 应输出 v18.20.4 npm -v # 应输出 9.9.2 或更高 # 5. 安装构建工具(编译 native addon 必需) sudo apt-get install -y build-essential python3注意:CentOS 7.9 用户需额外安装
gcc-c++和python3-devel,且 Node.js 18 的二进制包需从官网手动下载(https://nodejs.org/dist/v18.20.4/),因为 CentOS 7 的 glibc 版本过低,NodeSource 仓库不支持。
安装完成后,全局安装pnpm(Paperclip 官方推荐,比 npm/yarn 更快更省磁盘):
npm install -g pnpm pnpm -v # 验证输出4.2 初始化项目:使用 Paperclip CLI 脚手架
Paperclip 提供官方 CLI 工具,一键生成标准项目结构:
# 创建新项目 pnpm create paperclip@latest my-log-analyzer # 进入目录 cd my-log-analyzer # 安装依赖(自动识别 Node.js 版本,安装对应依赖) pnpm install # 启动开发环境 pnpm devCLI 会生成以下结构:
my-log-analyzer/ ├── package.json ├── tsconfig.json ├── public/ │ └── index.html ├── src/ │ ├── main.ts # Node.js 入口 │ ├── renderer.ts # React 入口(Web) │ ├── agent/ │ │ └── config.ts # Agent 配置(system prompt、memory path、LLM endpoint) │ ├── tools/ │ │ └── index.ts # 工具注册入口 │ └── utils/ │ └── logger.ts └── scripts/ └── setup.sh # 一键安装 Ollama + 下载 llama3 模型src/agent/config.ts是关键配置文件:
export const AGENT_CONFIG = { // LLM 配置:支持多种后端 llm: { provider: 'ollama', // 'ollama' | 'lmstudio' | 'vllm' | 'openai' model: 'llama3:8b', // Ollama 模型名 baseUrl: 'http://localhost:11434', // Ollama 默认地址 }, // 记忆存储路径(相对项目根目录) memoryPath: './data/memory.json', // 系统提示词(决定 Agent 行为) systemPrompt: ` You are a senior DevOps engineer analyzing application logs. Your goal is to identify errors, extract timestamps, and suggest root causes. Use tools only when necessary. If user asks for summary, reply directly. Always respond in Chinese. `.trim(), // 工具白名单(提高 LLM 调用准确率) availableTools: ['parse_log', 'list_files', 'search_in_logs'], };4.3 开发第一个工具:list_files工具实现
在src/tools/下新建list-files.ts:
import { Tool } from '../agent/lifecycle'; import * as fs from 'fs/promises'; import * as path from 'path'; export class ListFilesTool implements Tool { name = 'list_files'; description = 'List files and directories in a specified path. Supports filtering by extension.'; parameters = { type: 'object', properties: { path: { type: 'string', description: 'Directory path to list. Must be relative to project root.' }, extensions: { type: 'array', items: { type: 'string' }, description: 'Optional list of file extensions to filter (e.g., [".log", ".json"])' } }, required: ['path'] }; async execute(args: { path: string; extensions?: string[] }): Promise<string> { try { // 路径安全校验 if (args.path.includes('..') || args.path.startsWith('/')) { throw new Error('Invalid path: traversal detected'); } const fullPath = path.join(process.cwd(), args.path); const entries = await fs.readdir(fullPath, { withFileTypes: true }); const filtered = entries.filter(entry => { if (!args.extensions) return true; const ext = path.extname(entry.name).toLowerCase(); return args.extensions.some(e => e.toLowerCase() === ext); }); const result = filtered.map(entry => ({ name: entry.name, type: entry.isDirectory() ? 'directory' : 'file', size: entry.isDirectory() ? undefined : entry.size, })); return JSON.stringify(result, null, 2); } catch (err) { throw new Error(`Failed to list files: ${err.message}`); } } }然后在src/tools/index.ts中注册:
import { ListFilesTool } from './list-files'; export const TOOLS = [ new ListFilesTool(), // ... 其他工具 ];4.4 React UI 开发:构建日志分析面板
修改src/renderer.tsx,替换默认 UI:
import React, { useState, useEffect } from 'react'; import { ipcRenderer } from 'electron'; function LogAnalyzer() { const [files, setFiles] = useState<any[]>([]); const [selectedFile, setSelectedFile] = useState<string>(''); const [analysisResult, setAnalysisResult] = useState<string>(''); const [isAnalyzing, setIsAnalyzing] = useState(false); // 加载文件列表 useEffect(() => { ipcRenderer.send('run-tool', { name: 'list_files', arguments: { path: 'logs/' } }); const handleToolResult = (_: any, result: string) => { try { const parsed = JSON.parse(result); setFiles(parsed); } catch (e) { console.error('Parse files list failed:', e); } }; ipcRenderer.on('tool-result', handleToolResult); return () => ipcRenderer.off('tool-result', handleToolResult); }, []); const handleAnalyze = () => { if (!selectedFile) return; setIsAnalyzing(true); setAnalysisResult(''); ipcRenderer.send('run-tool', { name: 'parse_log', arguments: { file_path: `logs/${selectedFile}` } }); const handleParseResult = (_: any, result: string) => { setAnalysisResult(result); setIsAnalyzing(false); }; ipcRenderer.once('tool-result', handleParseResult); }; return ( <div className="log-analyzer"> <h2>日志分析面板</h2> <div className="file-selector"> <label>选择日志文件:</label> <select value={selectedFile} onChange={(e) => setSelectedFile(e.target.value)} > <option value="">-- 请选择 --</option> {files.map((file, i) => ( <option key={i} value={file.name}>{file.name}</option> ))} </select> </div> <button onClick={handleAnalyze} disabled={isAnalyzing || !selectedFile}> {isAnalyzing ? '分析中...' : '开始分析'} </button> {analysisResult && ( <div className="result-panel"> <h3>分析结果</h3> <pre>{analysisResult}</pre> </div> )} </div> ); } export default LogAnalyzer;4.5 启动与调试:全流程验证
启动 Ollama(如果未安装):
curl -fsSL https://ollama.com/install.sh | sh ollama run llama3:8b # 下载并运行模型启动 Paperclip:
pnpm dev控制台会输出:
> Starting Paperclip Agent... > Node.js Runtime listening on http://localhost:3001 > React UI running on http://localhost:3000 > Agent initialized with ID: 7a2b3c4d...打开浏览器
http://localhost:3000,看到日志分析面板;点击下拉框,选择
app.log(确保logs/app.log文件存在);点击“开始分析”,观察:
- Node.js 控制台输出
Executing tool: parse_log with args { file_path: "logs/app.log" }; - 几秒后,UI 显示 JSON
- Node.js 控制台输出