
目前很多笔记类工具都在往“云端 在线模型”的方向走而像VelocityNote这样主打本地优先 轻量 Markdown 本地 AI的小工具反而让人眼前一亮。虽然它叫 “tiny”但背后其实踩准了两个很关键的诉求笔记数据要自己在本地掌握AI 功能也不能完全依赖云端接口。这篇文章就从 VelocityNote 的设计思路说起逐步拆解一个极简 Markdown 笔记本需要的核心模块然后带大家从零实现一个简化版本包含笔记编辑、渲染、文件存储以及通过 Ollama 接入本地 AI 的完整流程。1. 从 VelocityNote 说起为什么“本地优先”的 Markdown 笔记开始流行1.1 VelocityNote 是什么解决什么问题VelocityNote 是一个轻量级 Markdown 笔记工具它的核心特点有两个一是文件格式直接使用 Markdown二是在本地接入 AI 能力。用一句话概括就是你仍然写 Markdown但写完之后可以召唤一个不依赖云端接口的 AI 助手来帮你整理、续写、总结或翻译。这里说的本地 AI通常指的是运行在个人电脑上的小型语言模型而不是调用在线大模型 API。很多使用传统云笔记的用户会有这么几个痛点笔记内容被绑定在某个软件生态里导出格式不统一部分笔记数据存放在云端换设备时需要重新登录和同步搜索、标签、双链等功能越来越丰富但真正高频用到的还是“写、存、找”三件事。VelocityNote 这类工具选择做减法把核心放在 Markdown 文件本身让数据回归普通文件。由于 Markdown 是纯文本格式用任何编辑器都能打开这也大大降低了数据被锁定的风险。1.2 本地 AI 与云端 AI 的区别本地 AI 和云端 AI 最大的区别在于推理发生的位置。云端 AI 需要把文本发送到远程服务器由云端算力生成结果后再返回本地 AI 则是在自己的电脑上完成推理文本不需要离开本机。从使用体验来看两者各有优势。云端模型的参数规模通常更大生成内容的质量和复杂性更高但同时会产生接口费用也存在数据隐私和网络延迟的问题。本地模型虽然模型体积受限于本机硬件但对于笔记场景常见的“总结一段内容”“翻译一段英文”“把要点列出来”等轻量任务已经足够。以 Ollama 为代表的本地模型运行工具让普通开发者也能用一条命令下载并运行模型然后将模型封装成本地 HTTP 接口供应用层调用。日常笔记场景里很多内容其实属于个人敏感信息比如工作日志、技术方案草稿、个人想法。使用本地 AI 时这些数据不会因为 AI 功能而外传到第三方服务器。这也是 VelocityNote 这类“本地优先 本地 AI”产品在技术圈受到关注的重要原因之一。1.3 这套方案适合谁如果你属于下面几类开发者VelocityNote 的思路和本文的实战内容会比较值得参考平时用 Markdown 记录技术笔记希望保留纯文本文件便于版本管理。想要体验本地 AI但又不想配置复杂的前置环境。对笔记数据的隐私比较敏感希望所有内容默认保存在本地。正在考虑给自己做一个定制化的笔记工具不满足于现有软件的功能边界。当然文章中的实现代码只是还原 VelocityNote 的核心理念并不是它的源码但设计思路和工程方案可以迁移到自己的项目中。2. 环境准备与基础技术选型2.1 运行环境本文的实践会使用 Node.js 来搭建一个简单的 Web 服务并通过 Ollama 运行本地模型。示例环境以常见的 Windows / macOS / Linux 作为说明对象具体版本不需要完全一致重点在于理解配置思路Node.js建议使用 18 及以上版本因为示例代码中会直接用内置 fetch 请求 Ollama 接口。包管理工具npmNode.js 安装时会自带的。Ollama本地模型运行时负责下载和启动模型并提供 HTTP API。浏览器Chrome、Edge 或任何现代浏览器均可用于访问笔记界面。模型本文以 Ollama 上的通用中英文模型为例如qwen2.5:7b你也可以根据自己的显存和内存选择更小的模型例如qwen2.5:3b。版本需要根据你的项目实际情况调整本文示例以常见环境为例重点演示配置思路。2.2 安装 OllamaOllama 是目前接入本地模型比较方便的工具之一。它把模型下载、模型启动、HTTP 服务封装成几条命令开发者不需要关心底层推理框架的细节。安装完成后可以通过命令行确认版本ollama --version然后下载一个模型ollama pull qwen2.5:7b下载完成后启动模型服务。Ollama 默认会在11434端口提供 API使用下面的命令可以查看服务状态ollama serve看到类似 “listening on 127.0.0.1:11434” 的日志说明本地模型服务已经就绪。需要注意的是ollama serve和ollama run是不一样的serve只启动后台 API 服务而run会进入交互式对话界面。在我们的笔记应用里只需要serve启动服务即可。2.3 前端与后端技术选型在实现一个极简 Markdown 笔记本时技术栈越简单越好避免一开始就陷入复杂的工程配置。因此本文采用后端Node.js Express提供笔记读写接口和 AI 代理接口。前端原生 HTML JavaScript CSS通过fetch调用后端接口。渲染使用marked解析 Markdown 文本生成 HTML。存储直接使用本地文件系统每篇笔记保存为一个.md文件不引入数据库。本地 AI通过 Ollama 的/api/generate接口完成文本生成。这样的选择可以让整个项目的最小运行成本降低也便于理解笔记类软件最核心的读写链路。3. 核心功能拆解Markdown 笔记本的四大模块3.1 笔记的编辑与渲染Markdown 笔记本的第一个核心模块是编辑与渲染。编辑指的是用户输入 Markdown 文本渲染指的是把 Markdown 转换成带格式的 HTML。Markdown 本身是一种轻量级标记语言用#、**、-、等符号表达标题、加粗、列表、代码等格式。例如# 标题 这是一段 **加粗** 文本。 - 列表项1 - 列表项2 js console.log(hello);渲染时前端通常使用 marked、markdown-it 这类库。以 marked 为例它的核心 API 非常简单 js const html marked.parse(myMarkdownText); document.getElementById(preview).innerHTML html;在后端保存时存储的就是原始 Markdown 文本在展示时再渲染成 HTML。这样既保留了文本的通用性又能在页面上有良好的阅读体验。3.2 笔记文件的组织与存储当笔记数量变多时文件组织方式就很重要。最简单的方案是按目录存放每篇笔记一个.md文件。示例中笔记目录结构可以这样设计notes/ ├── 快速开始.md ├── 学习计划.md └── 会议记录.md后端只需要读取notes目录下的.md文件并返回文件名和内容。保存时把内容写回对应文件。这种方案的优势在于没有数据库依赖迁移和备份非常方便。可以直接用 Git 做版本管理记录笔记的历史变更。与其他工具兼容性好Obsidian、VS Code 等工具都能直接打开同样的 Markdown 文件。当然随着笔记数量增长这种扁平目录也会遇到检索、重命名、目录层级等问题这些可以在后续优化阶段逐步引入索引机制。3.3 本地 AI 的接入方式本地 AI 接入的关键在于把模型能力封装成应用能调用的接口。以 Ollama 为例模型启动后我们可以通过 HTTP 接口发送提示词并得到回复。一个最简单的 Ollama 请求片段如下curl http://localhost:11434/api/generate -d { model: qwen2.5:7b, prompt: 请用一句话总结什么是Markdown, stream: false }返回结果中会包含response字段即模型生成的文本。在 Node.js 后端里我们可以用内置fetch转发用户的请求。通过这样的方式前端只需要调用我们自己的后端接口/api/ai后端再与 Ollama 通信前端不需要关心模型名称、端口号等细节。这样也方便以后替换模型服务。3.4 本地 AI 在笔记场景的典型用法本地 AI 在笔记场景里可以有很多种用法这里列举几个比较常见的总结笔记把一篇较长的笔记内容交给模型让它输出摘要或要点。续写灵感写了一半的段落让模型根据上下文给出后续建议。翻译把英文笔记翻译成中文或反过来。标签推荐根据笔记内容让模型推荐几个合适的标签。格式规范化把口语化的内容改写成结构清晰的 Markdown。在 VelocityNote 的设计语境里AI 并不是要替代写作而是作为一种辅助能力存在。写笔记的人仍然掌握主动权AI 负责把初稿变得更有条理。4. 实战从零搭建极简 Markdown 本地 AI 笔记本4.1 项目结构与初始化首先创建一个项目目录并初始化 npm 项目mkdir velocity-note-demo cd velocity-note-demo npm init -y然后安装需要的依赖npm install express marked项目结构如下velocity-note-demo/ ├── notes/ # 存放 Markdown 笔记的目录 ├── public/ │ └── index.html # 前端页面 ├── server.js # 后端入口 ├── package.json └── .gitignore创建notes目录和public目录mkdir notes public4.2 后端接口笔记的 CRUD创建server.js实现简单的笔记读写接口// 文件路径server.js const express require(express); const fs require(fs); const path require(path); const app express(); const PORT 3000; const NOTES_DIR path.join(__dirname, notes); // 确保笔记目录存在 if (!fs.existsSync(NOTES_DIR)) { fs.mkdirSync(NOTES_DIR, { recursive: true }); } app.use(express.json()); app.use(express.static(public)); // 获取笔记列表 app.get(/api/notes, (req, res) { const files fs.readdirSync(NOTES_DIR).filter(f f.endsWith(.md)); const notes files.map(file { const filePath path.join(NOTES_DIR, file); const content fs.readFileSync(filePath, utf-8); return { name: file.replace(.md, ), content }; }); res.json(notes); }); // 获取单个笔记 app.get(/api/notes/:name, (req, res) { const fileName ${req.params.name}.md; const filePath path.join(NOTES_DIR, fileName); if (!fs.existsSync(filePath)) { return res.status(404).json({ error: 笔记不存在 }); } const content fs.readFileSync(filePath, utf-8); res.json({ name: req.params.name, content }); }); // 保存笔记 app.post(/api/notes/:name, (req, res) { const fileName ${req.params.name}.md; const filePath path.join(NOTES_DIR, fileName); const { content } req.body; fs.writeFileSync(filePath, content, utf-8); res.json({ ok: true }); }); // 删除笔记 app.delete(/api/notes/:name, (req, res) { const fileName ${req.params.name}.md; const filePath path.join(NOTES_DIR, fileName); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } res.json({ ok: true }); }); app.listen(PORT, () { console.log(VelocityNote demo running at http://localhost:${PORT}); });这里的接口设计比较简单主要覆盖了新增、读取、保存和删除四个操作。需要留意的是文件名作为 URL 参数传递时建议只允许笔记名称不要包含多层路径避免产生路径穿越问题。生产环境中还需要对参数做更严格的校验。4.3 前端页面编辑、预览与 AI 对话创建public/index.html实现一个双栏 Markdown 编辑器并加上 AI 对话面板!-- 文件路径public/index.html -- !DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / titleVelocityNote Demo/title style * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: PingFang SC, Microsoft YaHei, sans-serif; background: #f5f6f8; color: #333; } header { background: #fff; padding: 12px 20px; border-bottom: 1px solid #e0e0e0; display: flex; justify-content: space-between; align-items: center; } main { display: grid; grid-template-columns: 300px 1fr 1fr; height: calc(100vh - 60px); } .sidebar { background: #fff; border-right: 1px solid #e0e0e0; overflow-y: auto; padding: 12px; } .sidebar .note-item { padding: 10px; margin-bottom: 8px; border-radius: 6px; cursor: pointer; background: #fafafa; border: 1px solid #eee; } .sidebar .note-item:hover { background: #edf2ff; } .editor-pane textarea { width: 100%; height: 100%; border: none; resize: none; padding: 16px; font-size: 15px; line-height: 1.6; outline: none; font-family: JetBrains Mono, monospace; } .preview-pane { padding: 16px; overflow-y: auto; background: #fff; border-left: 1px solid #e0e0e0; } .preview-pane h1 { font-size: 24px; margin-bottom: 12px; } .preview-pane h2 { font-size: 20px; margin: 16px 0 8px; } .preview-pane p { margin-bottom: 12px; line-height: 1.7; } .preview-pane pre { background: #f6f8fa; padding: 12px; border-radius: 6px; overflow-x: auto; } .ai-panel { background: #fff; border-left: 1px solid #e0e0e0; display: flex; flex-direction: column; } .ai-panel h3 { padding: 12px; border-bottom: 1px solid #eee; font-size: 14px; } .ai-messages { flex: 1; overflow-y: auto; padding: 12px; } .ai-message { background: #f0f4ff; padding: 10px; border-radius: 8px; margin-bottom: 8px; white-space: pre-wrap; font-size: 14px; line-height: 1.6; } .ai-input { padding: 12px; border-top: 1px solid #eee; display: flex; gap: 8px; } .ai-input input { flex: 1; padding: 8px 12px; border: 1px solid #ddd; border-radius: 6px; outline: none; } .ai-input button { padding: 8px 16px; background: #3b82f6; color: #fff; border: none; border-radius: 6px; cursor: pointer; } .btn { padding: 6px 12px; background: #3b82f6; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; } /style /head body header strongVelocityNote Demo/strong div input idnoteName placeholder笔记名称 value我的笔记 / button classbtn onclicksaveNote()保存笔记/button button classbtn onclicknewNote()新建笔记/button /div /header main aside classsidebar idsidebar/aside div classeditor-pane textarea ideditor placeholder在这里输入 Markdown 内容.../textarea /div div classpreview-pane idpreview/div div classai-panel h3本地 AI 助手/h3 div classai-messages idaiMessages/div div classai-input input idaiPrompt placeholder输入指令例如总结这段笔记 / button onclickaskAI()发送/button /div /div /main script srchttps://cdn.jsdelivr.net/npm/marked12.0.2/marked.min.js/script script let currentNote 我的笔记; // 加载笔记列表 async function loadNotes() { const res await fetch(/api/notes); const notes await res.json(); const sidebar document.getElementById(sidebar); sidebar.innerHTML notes.map(n div classnote-item onclickopenNote(${n.name})${n.name}/div ).join(); if (notes.length 0) { openNote(notes[0].name); } } // 打开笔记 async function openNote(name) { const res await fetch(/api/notes/${encodeURIComponent(name)}); const note await res.json(); currentNote note.name; document.getElementById(noteName).value note.name; document.getElementById(editor).value note.content; renderPreview(); } // 渲染 Markdown 预览 function renderPreview() { const md document.getElementById(editor).value; const html marked.parse(md); document.getElementById(preview).innerHTML html; } // 保存笔记 async function saveNote() { const name document.getElementById(noteName).value; const content document.getElementById(editor).value; await fetch(/api/notes/${encodeURIComponent(name)}, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ content }) }); currentNote name; loadNotes(); } // 新建笔记 function newNote() { document.getElementById(noteName).value 新笔记 Date.now(); document.getElementById(editor).value # 新笔记\n\n开始记录吧...; renderPreview(); } // 调用本地 AI async function askAI() { const prompt document.getElementById(aiPrompt).value; const noteContent document.getElementById(editor).value; if (!prompt) return; const messages document.getElementById(aiMessages); messages.innerHTML div classai-message用户指令${prompt}/div; document.getElementById(aiPrompt).value ; const res await fetch(/api/ai, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ prompt, noteContent }) }); const data await res.json(); messages.innerHTML div classai-messageAI 回复${data.response}/div; messages.scrollTop messages.scrollHeight; } // 编辑器输入时实时预览 document.getElementById(editor).addEventListener(input, renderPreview); loadNotes(); /script /body /html这个页面的布局是四块左侧笔记列表、中间编辑器、右侧 Markdown 预览、最右侧 AI 对话面板。当前端比较简单但已经包含了笔记工具最核心的交互链路。需要注意前端通过script srchttps://cdn.jsdelivr.net/npm/marked...引入了marked库。如果你所在环境无法访问外网 CDN可以把marked.min.js下载到本地public目录再改成相对路径引用。4.4 接入 Ollama 实现本地 AI在server.js中加入 AI 接口让前端能请求本地模型。我们需要在后端增加一个/api/ai路由// 在 server.js 中app.use(express.static(public)); 之后添加 app.post(/api/ai, async (req, res) { const { prompt, noteContent } req.body; const fullPrompt ${prompt}\n\n笔记内容\n${noteContent || }; // 组装 Ollama 请求 const ollamaBody { model: qwen2.5:7b, prompt: fullPrompt, stream: false }; try { const response await fetch(http://localhost:11434/api/generate, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(ollamaBody) }); if (!response.ok) { return res.status(502).json({ error: Ollama 服务返回异常 }); } const data await response.json(); res.json({ response: data.response }); } catch (error) { res.status(500).json({ error: 无法连接本地 Ollama 服务, detail: error.message }); } });这里面的关键逻辑是把笔记内容和用户的指令拼接成一个完整的提示词发给模型。这样模型在回答时就能结合当前笔记的上下文而不是只能回答一个孤立的问题。把消息拼接到同一个prompt中是最简单但也最直接的方式。缺点是当笔记内容很长时会占用较多的模型输入窗口并且每次请求都要重复发送全文。更优化的做法是先对笔记内容做截断或摘要再拼接提示词这个在后续优化中再展开。4.5 运行与验证首先确保 Ollama 服务已经启动ollama serve再确认模型已经存在ollama list如果列表里没有qwen2.5:7b需要先拉取ollama pull qwen2.5:7b然后启动我们的笔记应用node server.js浏览器访问http://localhost:3000就能看到笔记工具界面。输入 Markdown 内容时右侧预览区会实时更新。点击“保存笔记”后刷新页面笔记列表会加载已保存的文件。在 AI 面板输入“总结这段笔记的要点”后端会调用本地模型生成结果。首次请求时由于模型需要加载到内存响应时间可能会比较长之后请求会明显变快。预期输出效果是AI 面板返回一段基于当前笔记内容的总结文本而不是一个固定的提示词模板回复。如果请求失败先检查 Ollama 服务是否在运行再检查模型名称是否正确。5. 常见问题与排查清单问题现象常见原因解决思路启动node server.js报Cannot find module express依赖未安装执行npm install express marked浏览器打开页面是空的public目录不存在或index.html命名错误确认项目根目录下有public/index.htmlMarkdown 显示为纯文本marked脚本没有正确加载检查前端是否引入 CDN或者把marked.min.js放到本地调用 AI 返回“无法连接本地 Ollama 服务”Ollama 没有启动或端口不是 11434检查ollama serve是否运行访问http://localhost:11434测试AI 返回model not found本地没有下载对应模型先执行ollama list查看模型缺少时执行ollama pull qwen2.5:7b保存笔记后中文文件名乱码部分终端或文件系统编码问题建议文件名统一使用英文或拼音避免特殊字符删除笔记失败文件名包含反斜杠或路径分隔符对文件名做白名单校验禁止包含/、\、..第一次 AI 请求特别慢模型正在加载到内存等待模型加载完成后续请求会变快也可以换成小模型页面样式错乱前端 CSS 或 HTML 结构损坏检查index.html中的style标签和main区域结构排查时建议按这个顺序先确认后端日志有没有报错再确认浏览器控制台有没有报错最后确认 Ollama 接口是否能访问。大多数问题都出在依赖安装和本地服务启动两个环节。6. 最佳实践与扩展思路6.1 数据安全与备份由于笔记是以.md文件形式保存在本地数据安全会比云笔记更可控但也意味着备份责任完全在自己。推荐把notes目录纳入 Git 仓库管理形成历史版本记录。如果是个人机密笔记建议对仓库做加密存储并且不要包含秘密内容在提交历史中。另外在实现后端保存接口时要注意路径安全。不能简单地用用户输入拼接文件路径而要限制文件名范围。一个比较稳妥的做法是给文件名做白名单校验function safeNoteName(name) { return /^[a-zA-Z0-9_\-\u4e00-\u9fa5]$/.test(name); }只有在名称通过校验时才允许访问避免../之类的路径穿越问题。6.2 性能与内存控制本地模型的负载和内存占用是实际使用中最需要关注的。7B 或更大的模型通常需要 6GB 以上可用内存建议根据机器配置选择合适的模型档位。如果机器内存有限可以从qwen2.5:3b或llama3.2:3b开始尝试。在使用 Ollama 时可以在请求中设置options控制模型参数比如限制输出长度const ollamaBody { model: qwen2.5:7b, prompt: fullPrompt, stream: false, options: { num_predict: 512 } };与此同时笔记内容如果太长后端可以先做简单截断function trimNote(content, maxLen 2000) { return content.length maxLen ? content.slice(0, maxLen) \n... : content; }这样可以降低模型输入长度缩短推理时间。6.3 可扩展方向目前的 Demo 只实现了最基本的笔记功能距离一个可日常使用的笔记本还有一定差距。下面几个方向可以作为下一步扩展的参考全文搜索引入简单的倒排索引或者使用 Node.js 的grep能力实现按关键词搜索笔记内容。目录树支持多级目录而不是扁平的单层目录。标签系统在笔记头部加入 YAML front matter保存标签和描述信息。AI 流式输出把 Ollama 的stream: true接入 WebSocket 或 SSE实现打字机效果。双链笔记通过[[笔记名]]语法建立笔记之间的引用关系。桌面客户端使用 Electron 或 Tauri 封装成桌面应用进一步贴近 VelocityNote 的使用体验。这些功能并不会改变“Markdown 文件 本地 AI”的核心架构只是在它之上不断叠加新的交互能力。7. 总结VelocityNote 的价值不在于功能多强大而在于它把“Markdown 笔记 本地 AI”这两个理念用一种轻量级的方式整合到了一起。笔记数据保留为普通文件AI 能力跑在本地二者结合之后既保留了写作的纯粹性也获得了智能辅助的能力。本文顺着这个思路拆解了 Markdown 笔记工具的核心模块编辑渲染、文件存储、本地 AI 接入并通过 Node.js 和 Ollama 实现了一个可运行的极简版本。如果你也想做一个自己的笔记工具或者只是想体验一下本地模型和 Markdown 的结合这个 Demo 可以作为一个很好的起点。下一步可以尝试把流式输出、全文搜索、标签系统逐步加进去最终打磨成一个真正适合自己的工具。