NeverWrite 深度实战:用 Electron+Rust 打造 AI 可审阅的多窗格 Markdown 工作区——从混合架构到 Diff 引擎的全链路解析
一、为什么我们需要一个"不一样"的 Markdown 编辑器?
如果你是个程序员,你大概用过这些工具写 Markdown:VS Code + 插件、Typora、Obsidian、MarkText……但说实话,每个都有让你抓狂的时刻。
痛点一:多任务切换的灾难。 写文档的时候,你左手开着需求文档,右手开着 API 文档,中间是编辑器,旁边还飘着一个预览窗口。四个窗口来回 Alt+Tab,思路被反复打断,等切回来的时候——"我刚才要写什么来着?"
痛点二:AI 编辑的"黑箱恐惧"。 2026 年了,谁写文档不用 AI 辅助?但问题来了:让 AI 改一段文字,它"唰"地一下就替换了,你根本不知道它改了哪里、为什么改。万一它把关键参数改错了呢?万一它删了你精心写的某段话呢?你得一行一行对比才能发现问题,比你自己重写还慢。
痛点三:格式孤岛。 写 Markdown 的时候,突然要引用一张图、处理一个 CSV 数据、查看一个 PDF 论文——得,切工具吧。每个格式一个应用,桌面开了一堆,资源管理器更乱。
这三座大山压了多少年?终于,2026 年 5 月,一个叫 NeverWrite 的开源项目站出来说:"够了,我来解决。"
它给出的答案简洁而有力:
- 多窗格工作区:像 IDE 一样,编辑、预览、AI 聊天、文件浏览,全在一个窗口里搞定
- AI 可审阅机制:AI 的每一处修改都要过你的"审核",接受或拒绝,你说了算
- 全格式支持:Markdown、CSV、PDF、图片、代码文件、甚至 Excalidraw 概念图,一个工作区全收纳
- 开源免费:Apache-2.0 协议,代码全透明
这不是又一个"加了 AI 功能的编辑器",而是从架构层面重新思考了"人机协作编辑"应该是什么样子。接下来,我们就从架构到代码,一层一层拆开它。
二、架构全貌:Electron + Rust 的混合范式
2.1 为什么是 Electron + Rust,而不是 Tauri?
看到 Electron + Rust 的组合,很多人第一反应是:"为什么不用 Tauri?Tauri 不就是干这个的吗?" 这个问题很好,但答案比你想的复杂。
先看 NeverWrite 的架构图(简化版):
┌─────────────────────────────────────────────────────────┐
│ Electron 41 (主进程) │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ Window Mgr │ │ IPC Bridge │ │ File Watcher │ │
│ └──────┬───────┘ └──────┬───────┘ └───────┬───────┘ │
│ │ │ │ │
│ ┌──────┴──────────────────┴───────────────────┴───────┐ │
│ │ React 19 (渲染进程) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │ │
│ │ │ Editor │ │ AI Panel │ │ Graph │ │ Sidebar │ │ │
│ │ │ Pane │ │ Review │ │ View │ │ Explorer │ │ │
│ │ └────┬─────┘ └────┬─────┘ └──────────┘ └─────────┘ │ │
│ └───────┼─────────────┼───────────────────────────────┘ │
└──────────┼─────────────┼─────────────────────────────────┘
│ │
┌──────┴─────┐ ┌─────┴──────────────────────┐
│ Rust Crate │ │ Rust → WASM (Diff Engine) │
│ vault/ │ │ crates/diff/ │
│ index/ │ │ → 编译为 .wasm │
│ ai/types │ │ → JS 通过 wasm-pack 调用 │
└────────────┘ └─────────────────────────────┘
选择 Electron 的理由很实际:
AI 运行时嵌入:NeverWrite 需要捆绑 Codex 和 Claude 的嵌入式运行时。Electron 的 Node.js 环境天然支持这种"主进程跑重活"的模式,而 Tauri 的 Rust 后端要嵌入 Node.js 运行时就非常别扭——你等于在 Rust 里再跑一个 JS 引擎,多此一举。
React 生态:NeverWrite 的 UI 层是 React 19,整个组件生态(Excalidraw、代码编辑器、图谱视图)都依赖 Web 生态。Electron 对 Web 标准的支持最完整,不用担心 WebView 的兼容性地雷。
Web Clipper 复用:浏览器插件(WXT + React)和桌面端共享大量 UI 代码,Electron 让这种复用毫无摩擦。
但 Electron 的问题也明显:
- 内存占用大(基础就要 200MB+)
- 文件系统操作慢(Node.js fs 在大量文件时性能不佳)
- 原生能力弱(窗口管理、系统级操作都需要额外方案)
所以 Rust 补位了:
vaultcrate:文件系统扫描、解析、监控,Rust 原生文件 I/O 比 Node.js 快 5-10 倍diffcrate → WASM:差异引擎编译成 WebAssembly,在渲染进程中以接近原生的速度运行indexcrate:链接解析、全文搜索原语,处理大型知识库时不会被 GC 暂停卡顿
这是一个务实的架构选择:Electron 负责它擅长的(UI 渲染、生态复用、AI 运行时嵌入),Rust 负责它擅长的(性能敏感的底层计算)。 不追求架构的"纯洁",而追求工程的最优解。
2.2 单体仓库布局解析
neverwrite/
├── apps/
│ ├── desktop/ # Electron + React 桌面端
│ │ ├── src/
│ │ │ ├── main/ # Electron 主进程
│ │ │ ├── renderer/ # React 渲染进程
│ │ │ └── preload/ # IPC 预加载脚本
│ │ ├── package.json
│ │ └── electron-builder.yml
│ └── web-clipper/ # 浏览器剪辑插件 (WXT + React)
│ ├── src/
│ ├── package.json # pnpm 10.33.0
│ └── wxt.config.ts
├── crates/
│ ├── ai/ # 共享 AI 领域类型
│ ├── diff/ # Rust 差异引擎 → WASM
│ ├── index/ # 索引、链接解析、搜索原语
│ ├── types/ # 共享数据传输对象
│ └── vault/ # 文件扫描、解析、监控、PDF 识别
├── Cargo.toml # Rust workspace
└── package.json # 根级 npm workspace
这个布局的精妙之处在于 crates/ 目录。它不是把 Rust 代码塞进 Node 项目的 native/ 文件夹里凑合,而是作为一个独立的 Cargo workspace,有自己的依赖管理和测试体系。这样做的好处:
- Rust 部分可以
cargo test独立测试 diffcrate 可以单独编译为 WASM,不影响其他 crate- 前端同学改 UI 不需要碰 Rust,后端同学优化性能不需要碰 React
三、核心机制一:多窗格工作区的实现原理
3.1 窗格管理架构
多窗格不是"开多个浏览器窗口"那么简单。NeverWrite 的窗格管理更像 VS Code 的编辑器组(Editor Groups),但更灵活:
// 窗格类型定义 (简化版)
interface Pane {
id: string;
type: 'editor' | 'preview' | 'ai-chat' | 'graph' | 'sidebar';
content: ContentRef;
position: { x: number; y: number; width: number; height: number };
parent?: string; // 父窗格 ID
split?: 'horizontal' | 'vertical';
detached?: boolean; // 是否为独立窗口
}
// 窗格状态管理
interface WorkspaceState {
panes: Map<string, Pane>;
activePane: string;
layout: LayoutTree; // 二叉树布局描述
}
关键设计点:
二叉树布局:窗格的布局用二叉树描述,每个叶子节点是一个窗格,内部节点记录分割方向和比例。这使得窗格的拆分、合并、调整大小都可以用递归算法高效处理。
Detached 窗口:任何窗格都可以"撕裂"成独立窗口(类似浏览器把 Tab 拖出去变成新窗口),但数据仍然共享。实现方式是通过 Electron 的
BrowserWindow创建新窗口,共享同一个IPC Bridge。命令面板:快捷键驱动的操作入口,
Cmd+P呼出,支持模糊搜索。本质上是一个路由分发器:
// 命令注册
const commands = new Map<string, CommandHandler>();
function registerCommand(id: string, handler: CommandHandler) {
commands.set(id, handler);
}
// 命令面板的执行逻辑
function executeCommand(id: string, ...args: unknown[]) {
const handler = commands.get(id);
if (!handler) {
console.warn(`Command not found: ${id}`);
return;
}
return handler.execute(args);
}
// 内置命令示例
registerCommand('pane.splitRight', {
execute: () => splitActivePane('vertical'),
label: 'Split Pane Right',
keybinding: 'Cmd+\\'
});
registerCommand('ai.review.accept', {
execute: () => acceptCurrentDiff(),
label: 'Accept AI Change',
keybinding: 'Cmd+Shift+A'
});
3.2 全格式编辑的统一抽象
NeverWrite 支持多种文件格式,但每个格式的编辑器差异巨大。它怎么做到"一个工作区搞定所有格式"?
答案是 Editor Adapter 模式:
// 统一的编辑器接口
interface EditorAdapter {
// 内容操作
getContent(): string;
setContent(content: string): void;
// 变更追踪
onChange(callback: (change: ContentChange) => void): void;
// Diff 集成(核心!AI 审阅需要)
applyDiff(diff: TextDiff): void;
revertDiff(diff: TextDiff): void;
// 保存
save(): Promise<void>;
// 生命周期
mount(container: HTMLElement): void;
unmount(): void;
}
// Markdown 编辑器适配器
class MarkdownEditorAdapter implements EditorAdapter {
private editor: CodeMirror.Editor;
private diffDecorations: DecorationSet;
getContent(): string {
return this.editor.state.doc.toString();
}
applyDiff(diff: TextDiff): void {
const changes = diff.hunks.map(hunk => ({
from: hunk.oldStart,
to: hunk.oldStart + hunk.oldLines,
insert: hunk.newContent
}));
this.editor.dispatch({
changes,
annotations: [Annotation.define('ai-diff', true)]
});
this.highlightDiffRegions(diff);
}
private highlightDiffRegions(diff: TextDiff): void {
// 用不同颜色高亮显示新增(绿色)和删除(红色)的区域
// 但不真正应用——用户需要手动接受
const decorations = diff.hunks.flatMap(hunk => [
Decoration.inline({ class: 'diff-add' }).range(hunk.newStart, hunk.newEnd),
]);
this.diffDecorations = Decoration.set(decorations);
}
}
// CSV 编辑器适配器
class CsvEditorAdapter implements EditorAdapter {
private grid: Handsontable;
getContent(): string {
return this.grid.getData().map(row => row.join(',')).join('\n');
}
applyDiff(diff: TextDiff): void {
// CSV 的 diff 需要解析成行列操作
const cellChanges = parseCsvDiff(diff);
cellChanges.forEach(change => {
this.grid.setDataAtCell(change.row, change.col, change.newValue);
});
}
}
这种设计让 AI 审阅机制可以独立于文件格式工作——不管你在编辑什么文件,AI 的修改都会通过 applyDiff / revertDiff 接口呈现,用户体验完全一致。
四、核心机制二:AI 可审阅的 Diff 引擎——从 Rust WASM 到 React 渲染
这是 NeverWrite 最核心的技术创新,也是它区别于所有"AI 编辑器"的关键。我们逐层拆解。
4.1 为什么 Diff 引擎要用 Rust + WASM?
AI 修改文本后,需要做精确的字符级 Diff,让用户能看清楚每一处改动。这个操作有几个苛刻要求:
- 实时性:AI 流式输出,Diff 计算不能成为瓶颈
- 精确性:必须是字符级 Diff,不是行级 Diff,否则修改一行中间某几个字就看不出区别
- 可逆性:每个 Diff 操作必须能精确回退
- 内存效率:大文档(几万行 Markdown)的 Diff 不能吃光内存
JavaScript 的 diff 库(如 diff-match-patch、jsdiff)在几千行以内的文档表现尚可,但到了大文档,性能和内存都扛不住。Rust 编译成 WASM 后:
- 运行速度接近原生(比纯 JS 快 5-20 倍)
- 内存占用可控(Rust 无 GC,WASM 线性内存模型)
- 可以在渲染进程的 Web Worker 中运行,不阻塞 UI
4.2 Diff 引擎的 Rust 实现
// crates/diff/src/lib.rs (核心结构)
use wasm_bindgen::prelude::*;
/// 一个差异块
#[wasm_bindgen]
#[derive(Clone, Debug)]
pub struct DiffHunk {
/// 旧文本起始位置
old_start: usize,
/// 旧文本长度
old_len: usize,
/// 新文本起始位置
new_start: usize,
/// 新文本长度
new_len: usize,
/// 差异类型
kind: DiffKind,
}
#[wasm_bindgen]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum DiffKind {
/// 新增内容
Insert,
/// 删除内容
Delete,
/// 未修改
Equal,
}
/// 差异计算结果
#[wasm_bindgen]
pub struct DiffResult {
hunks: Vec<DiffHunk>,
/// 统计信息
stats: DiffStats,
}
#[wasm_bindgen]
impl DiffResult {
/// 获取所有差异块
pub fn hunks(&self) -> Vec<DiffHunk> {
self.hunks.clone()
}
/// 获取统计信息
pub fn stats(&self) -> DiffStats {
self.stats.clone()
}
}
/// 计算两个文本之间的差异
#[wasm_bindgen]
pub fn compute_diff(old_text: &str, new_text: &str) -> DiffResult {
// 使用 Myers diff 算法的优化实现
// 支持字符级和行级两种粒度
let hunks = myers_diff(old_text, new_text, Granularity::Char);
let stats = DiffStats {
insertions: hunks.iter()
.filter(|h| h.kind == DiffKind::Insert)
.map(|h| h.new_len)
.sum(),
deletions: hunks.iter()
.filter(|h| h.kind == DiffKind::Delete)
.map(|h| h.old_len)
.sum(),
unchanged: hunks.iter()
.filter(|h| h.kind == DiffKind::Equal)
.map(|h| h.old_len)
.sum(),
};
DiffResult { hunks, stats }
}
/// Myers diff 算法实现(优化版)
fn myers_diff(old: &str, new: &str, granularity: Granularity) -> Vec<DiffHunk> {
let old_tokens = tokenize(old, granularity);
let new_tokens = tokenize(new, granularity);
let n = old_tokens.len();
let m = new_tokens.len();
// 前后缀修剪:跳过相同的首尾部分
let prefix_len = common_prefix_len(&old_tokens, &new_tokens);
let suffix_len = common_suffix_len(
&old_tokens[prefix_len..],
&new_tokens[prefix_len..]
);
let trimmed_old = &old_tokens[prefix_len..n - suffix_len];
let trimmed_new = &new_tokens[prefix_len..m - suffix_len];
if trimmed_old.is_empty() && trimmed_new.is_empty() {
return vec![DiffHunk {
old_start: 0, old_len: n,
new_start: 0, new_len: m,
kind: DiffKind::Equal,
}];
}
// 标准 Myers 算法
let max_d = trimmed_old.len() + trimmed_new.len();
let mut v = vec![0i32; 2 * max_d + 1];
let mut trace = Vec::new();
let (v_offset, _) = run_myers(trimmed_old, trimmed_new, &mut v, &mut trace);
// 回溯编辑路径
let edits = backtrack(trace, trimmed_old, trimmed_new, v_offset);
// 将编辑操作转换为 DiffHunk
edits_to_hunks(edits, prefix_len, old_tokens, new_tokens)
}
/// 将 WASM 绑定暴露给 JS
#[wasm_bindgen]
pub fn create_diff_review(
original: &str,
modified: &str,
context_lines: usize,
) -> JsValue {
let result = compute_diff(original, modified);
// 序列化为 JSON,供 React 端使用
let review_data = DiffReviewData {
hunks: result.hunks.iter().map(|h| DiffHunkData {
old_start: h.old_start,
old_len: h.old_len,
new_start: h.new_start,
new_len: h.new_len,
kind: match h.kind {
DiffKind::Insert => "insert",
DiffKind::Delete => "delete",
DiffKind::Equal => "equal",
},
old_content: original[h.old_start..h.old_start + h.old_len].to_string(),
new_content: modified[h.new_start..h.new_start + h.new_len].to_string(),
}).collect(),
stats: result.stats,
};
serde_wasm_bindgen::to_value(&review_data)
.unwrap_or(JsValue::NULL)
}
4.3 前端集成:审阅 UI 的实现
Rust WASM 计算出 Diff 后,React 端需要把差异渲染成可视化的审阅界面:
// AI 审阅面板组件 (简化版)
import { useEffect, useState } from 'react';
import { createDiffReview } from '@neverwrite/diff-wasm';
interface AIDiffReviewProps {
originalContent: string;
aiModifiedContent: string;
onAccept: (hunkId: string) => void;
onReject: (hunkId: string) => void;
onAcceptAll: () => void;
onRejectAll: () => void;
}
export function AIDiffReview({
originalContent,
aiModifiedContent,
onAccept,
onReject,
onAcceptAll,
onRejectAll,
}: AIDiffReviewProps) {
const [reviewData, setReviewData] = useState<DiffReviewData | null>(null);
const [acceptedHunks, setAcceptedHunks] = useState<Set<string>>(new Set());
const [rejectedHunks, setRejectedHunks] = useState<Set<string>>(new Set());
useEffect(() => {
// 调用 WASM Diff 引擎
const result = createDiffReview(
originalContent,
aiModifiedContent,
3 // 上下文行数
);
setReviewData(result);
}, [originalContent, aiModifiedContent]);
if (!reviewData) return <LoadingSpinner />;
const pendingHunks = reviewData.hunks.filter(
h => h.kind !== 'equal' &&
!acceptedHunks.has(h.id) &&
!rejectedHunks.has(h.id)
);
return (
<div className="ai-diff-review">
<div className="review-header">
<span className="review-title">
AI 建议修改 {pendingHunks.length} 处
</span>
<div className="review-actions">
<button onClick={onAcceptAll}>
全部接受 ({reviewData.stats.insertions} 处新增)
</button>
<button onClick={onRejectAll}>
全部拒绝 ({reviewData.stats.deletions} 处删除)
</button>
</div>
</div>
<div className="review-body">
{reviewData.hunks.map((hunk, idx) => {
if (hunk.kind === 'equal') {
return <ContextLine key={idx} content={hunk.newContent} />;
}
const hunkId = `hunk-${idx}`;
const isAccepted = acceptedHunks.has(hunkId);
const isRejected = rejectedHunks.has(hunkId);
return (
<DiffHunkView
key={hunkId}
hunk={hunk}
hunkId={hunkId}
isAccepted={isAccepted}
isRejected={isRejected}
onAccept={() => {
setAcceptedHunks(prev => new Set([...prev, hunkId]));
onAccept(hunkId);
}}
onReject={() => {
setRejectedHunks(prev => new Set([...prev, hunkId]));
onReject(hunkId);
}}
/>
);
})}
</div>
</div>
);
}
// 单个差异块的渲染
function DiffHunkView({ hunk, hunkId, isAccepted, isRejected, onAccept, onReject }) {
return (
<div className={`diff-hunk ${isAccepted ? 'accepted' : ''} ${isRejected ? 'rejected' : ''}`}>
{hunk.kind === 'delete' && (
<div className="diff-line deleted">
<span className="line-prefix">-</span>
<span className="line-content">{hunk.oldContent}</span>
</div>
)}
{hunk.kind === 'insert' && (
<div className="diff-line inserted">
<span className="line-prefix">+</span>
<span className="line-content">{hunk.newContent}</span>
</div>
)}
{!isAccepted && !isRejected && (
<div className="hunk-actions">
<button className="accept-btn" onClick={onAccept}>
✓ 接受
</button>
<button className="reject-btn" onClick={onReject}>
✗ 拒绝
</button>
</div>
)}
</div>
);
}
4.4 审阅流程的完整链路
让我用一个具体场景说明整个链路:
- 用户写了一段 Markdown,让 AI 优化措辞
- AI 在后台生成修改后的文本
- Rust Diff 引擎计算原始文本和 AI 修改后文本的差异
- 差异结果序列化后传给 React
- React 渲染审阅面板,绿色标记新增、红色标记删除
- 用户逐个审查,点"接受"或"拒绝"
- 接受的修改通过
applyDiff写入编辑器,拒绝的修改丢弃 - 最终结果保存到文件
这个流程的关键洞察是:AI 的输出不是"最终答案",而是"建议"。 所有的决策权在用户手中。这和 Git 的 code review 是同一个哲学——改动需要经过审核才能合入。
五、核心机制三:知识导航与反向链接
5.1 Vault 索引引擎
NeverWrite 的 Vault 概念借鉴了 Obsidian,但实现方式完全不同。Obsidian 用纯 JS 做文件监控和索引,在大型知识库(1万+ 文件)时明显卡顿。NeverWrite 用 Rust 实现:
// crates/vault/src/scanner.rs
use notify::{Watcher, RecursiveMode, Event, EventKind};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
/// Vault 扫描器:负责文件发现、解析和变更监控
pub struct VaultScanner {
root: PathBuf,
files: HashMap<PathBuf, FileMetadata>,
index: VaultIndex,
watcher: notify::RecommendedWatcher,
}
/// 文件元数据
#[derive(Clone, Debug)]
pub struct FileMetadata {
pub path: PathBuf,
pub title: String,
pub tags: Vec<String>,
pub links: Vec<String>, // [[wikilinks]]
pub frontmatter: Frontmatter,
pub modified_at: u64,
pub file_type: FileType,
}
#[derive(Clone, Copy, Debug)]
pub enum FileType {
Markdown,
Csv,
Pdf,
Image,
Code,
Excalidraw,
PlainText,
}
impl VaultScanner {
pub fn new(root: impl AsRef<Path>) -> Result<Self, VaultError> {
let root = root.as_ref().to_path_buf();
let (tx, rx) = mpsc::channel();
// 初始化文件监控
let watcher = notify::recommended_watcher(tx)?;
Ok(Self {
root,
files: HashMap::new(),
index: VaultIndex::new(),
watcher,
})
}
/// 扫描整个 Vault,构建索引
pub fn scan(&mut self) -> Result<(), VaultError> {
self.files.clear();
self.index.clear();
for entry in walkdir::WalkDir::new(&self.root)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
if entry.file_type().is_file() {
self.index_file(entry.path())?;
}
}
// 构建反向链接索引
self.index.build_backlinks();
// 启动文件监控
self.watcher.watch(&self.root, RecursiveMode::Recursive)?;
Ok(())
}
/// 索引单个文件
fn index_file(&mut self, path: &Path) -> Result<(), VaultError> {
let content = std::fs::read_to_string(path)?;
let file_type = detect_file_type(path);
let metadata = match file_type {
FileType::Markdown => self.parse_markdown(path, &content)?,
FileType::Csv => self.parse_csv(path, &content)?,
FileType::Pdf => self.parse_pdf_metadata(path)?,
_ => FileMetadata::simple(path, file_type),
};
self.index.add_file(&metadata);
self.files.insert(path.to_path_buf(), metadata);
Ok(())
}
/// 解析 Markdown 文件,提取链接和标签
fn parse_markdown(&self, path: &Path, content: &str) -> Result<FileMetadata, VaultError> {
// 使用 pulldown-cml 解析 Markdown
let mut links = Vec::new();
let mut tags = Vec::new();
let mut title = String::new();
let mut frontmatter = Frontmatter::default();
// 解析 frontmatter (YAML)
if content.starts_with("---") {
if let Some(fm_end) = content[3..].find("---") {
let fm_str = &content[3..fm_end + 3];
frontmatter = serde_yaml::from_str(fm_str).unwrap_or_default();
tags = frontmatter.tags.clone();
title = frontmatter.title.clone().unwrap_or_default();
}
}
// 解析 wikilinks: [[target]] 和 [[target|display]]
let wikilink_re = regex::Regex::new(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]")?;
for cap in wikilink_re.captures_iter(content) {
links.push(cap[1].to_string());
}
// 解析标签: #tag
let tag_re = regex::Regex::new(r"(?:^|\s)#([a-zA-Z\u4e00-\u9fff][\w/\-]*)")?;
for cap in tag_re.captures_iter(content) {
tags.push(cap[1].to_string());
}
// 提取标题
if title.is_empty() {
if let Some(h1) = content.lines().find(|l| l.starts_with("# ")) {
title = h1[2..].trim().to_string();
} else {
title = path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Untitled")
.to_string();
}
}
Ok(FileMetadata {
path: path.to_path_buf(),
title,
tags,
links,
frontmatter,
modified_at: std::fs::metadata(path)?
.modified()?
.duration_since(std::time::UNIX_EPOCH)?
.as_secs(),
file_type: FileType::Markdown,
})
}
}
5.2 反向链接索引
反向链接是知识图谱的核心:"哪些文件引用了当前文件?" 这需要维护一个从目标文件到来源文件的映射:
// crates/index/src/backlinks.rs
use std::collections::{HashMap, HashSet};
/// 反向链接索引
pub struct BacklinkIndex {
/// 文件 → 引用它的文件列表
backlinks: HashMap<String, HashSet<String>>,
/// 文件 → 它引用的文件列表(正向链接)
forward_links: HashMap<String, HashSet<String>>,
}
impl BacklinkIndex {
pub fn new() -> Self {
Self {
backlinks: HashMap::new(),
forward_links: HashMap::new(),
}
}
/// 添加文件的链接关系
pub fn add_file_links(&mut self, source: &str, targets: &[String]) {
let targets_set: HashSet<String> = targets.iter().cloned().collect();
// 更新正向链接
self.forward_links.insert(source.to_string(), targets_set.clone());
// 更新反向链接
for target in &targets_set {
self.backlinks
.entry(target.clone())
.or_insert_with(HashSet::new)
.insert(source.to_string());
}
}
/// 获取指向某个文件的所有反向链接
pub fn get_backlinks(&self, target: &str) -> Vec<&str> {
self.backlinks
.get(target)
.map(|set| set.iter().map(|s| s.as_str()).collect())
.unwrap_or_default()
}
/// 获取某个孤立文件(没有任何链接的文件)
pub fn get_orphans(&self) -> Vec<&str> {
let all_files: HashSet<&str> = self.forward_links.keys()
.map(|s| s.as_str())
.collect();
all_files.into_iter()
.filter(|file| {
self.forward_links.get(*file).map_or(true, |links| links.is_empty())
&& self.backlinks.get(*file).map_or(true, |links| links.is_empty())
})
.collect()
}
}
5.3 图谱视图
基于反向链接索引,可以渲染知识图谱。NeverWrite 支持两种模式:
- 2D 图谱:类似 Obsidian 的力导向图,节点是文件,边是链接关系
- 3D 图谱:三维空间中的图谱,适合大规模知识库,可以用鼠标旋转缩放
3D 图谱使用 Three.js 渲染,力导向布局算法跑在 Web Worker 中,不会阻塞 UI。
六、核心机制四:Web Clipper——浏览器知识采集
6.1 WXT 插件架构
NeverWrite 的浏览器剪辑插件基于 WXT(Web Extension Tools)框架开发,支持 Chrome 和 Firefox:
// apps/web-clipper/src/entrypoints/popup/main.tsx
import { useState } from 'react';
import { storage } from 'wxt/storage';
export default function ClipperPopup() {
const [clipMode, setClipMode] = useState<'full' | 'selection' | 'bookmark'>('full');
const [targetFolder, setTargetFolder] = useState<string>('');
const handleClip = async () => {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) return;
// 发送剪辑指令到 content script
const result = await browser.tabs.sendMessage(tab.id, {
type: 'clip',
mode: clipMode,
folder: targetFolder,
});
if (result.success) {
showSuccessToast('内容已保存到 NeverWrite');
}
};
return (
<div className="clipper-popup">
<h3>保存到 NeverWrite</h3>
<div className="clip-mode">
<button
className={clipMode === 'full' ? 'active' : ''}
onClick={() => setClipMode('full')}
>
📄 整页保存
</button>
<button
className={clipMode === 'selection' ? 'active' : ''}
onClick={() => setClipMode('selection')}
>
✂️ 选区保存
</button>
<button
className={clipMode === 'bookmark' ? 'active' : ''}
onClick={() => setClipMode('bookmark')}
>
🔖 仅保存书签
</button>
</div>
<button className="clip-btn" onClick={handleClip}>
保存
</button>
</div>
);
}
6.2 内容提取与转换
剪辑的核心挑战是把网页内容转换成干净的 Markdown,去掉广告、导航栏等噪音:
// apps/web-clipper/src/utils/extractor.ts
import { Readability } from '@mozilla/readability';
interface ClipResult {
title: string;
content: string; // Markdown 格式
url: string;
author?: string;
publishDate?: string;
images: string[]; // 图片 URL 列表
}
export async function extractPageContent(
mode: 'full' | 'selection' | 'bookmark'
): Promise<ClipResult> {
if (mode === 'bookmark') {
return {
title: document.title,
content: '',
url: location.href,
images: [],
};
}
if (mode === 'selection') {
const selection = window.getSelection();
const selectedHtml = selection?.toString() || '';
return {
title: document.title,
content: htmlToMarkdown(selectedHtml),
url: location.href,
images: extractImages(selectedHtml),
};
}
// 整页保存:使用 Readability 提取正文
const documentClone = document.cloneNode(true) as Document;
const reader = new Readability(documentClone);
const article = reader.parse();
if (!article) {
throw new Error('无法提取页面内容');
}
return {
title: article.title,
content: htmlToMarkdown(article.content),
url: location.href,
author: article.byline || undefined,
publishDate: article.datePublished || undefined,
images: extractImages(article.content),
};
}
/// HTML 转 Markdown
function htmlToMarkdown(html: string): string {
// 使用 Turndown 库转换
const turndownService = new TurndownService({
headingStyle: 'atx',
bulletListMarker: '-',
codeBlockStyle: 'fenced',
});
// 自定义规则:处理代码块
turndownService.addRule('fencedCodeBlock', {
filter: (node) => {
return node.nodeName === 'PRE' &&
node.querySelector('code') !== null;
},
replacement: (content, node) => {
const code = node.querySelector('code')!;
const language = code.className.replace('language-', '') || '';
return `\n\n\`\`\`${language}\n${code.textContent}\n\`\`\`\n\n`;
}
});
return turndownService.turndown(html);
}
6.3 桌面端与插件的通信
剪辑的内容怎么从浏览器插件传到桌面端?NeverWrite 使用了 本地 HTTP API + 文件系统监控 的混合方案:
浏览器插件 → HTTP POST localhost:17321/api/clip → Electron 主进程 → 写入 Vault 目录
↓
Rust File Watcher 检测到新文件
↓
更新索引 → 通知渲染进程
为什么不直接用 WebSocket?因为 WebSocket 需要保持长连接,而插件可能在桌面端启动前就安装了。HTTP API 更健壮——桌面端没启动时,插件把内容暂存在本地,桌面端启动后自动同步。
七、性能优化:让大型知识库飞起来
7.1 文件监控的性能陷阱
当你有 5000+ Markdown 文件的知识库时,文件监控是最容易出问题的地方。NeverWrite 的 Rust 实现做了几层优化:
// crates/vault/src/watcher.rs
use notify::{Config, Event, EventKind, RecommendedWatcher, Watcher};
use std::time::{Duration, Instant};
/// 带去抖动的文件监控器
pub struct DebouncedWatcher {
inner: RecommendedWatcher,
/// 去抖动窗口:同一文件在 100ms 内的多次事件只处理最后一次
debounce_window: Duration,
/// 待处理的事件缓冲
pending: HashMap<PathBuf, (Instant, FileChangeEvent)>,
}
impl DebouncedWatcher {
pub fn new(callback: Box<dyn Fn(FileChangeEvent)>) -> Result<Self, VaultError> {
let callback = Arc::new(Mutex::new(callback));
let inner = RecommendedWatcher::new(
move |res: Result<Event, notify::Error>| {
if let Ok(event) = res {
let callback = callback.lock().unwrap();
// 只关注文件内容变更,忽略元数据变更
match event.kind {
EventKind::Create(_) |
EventKind::Modify(notify::EventKind::ModifyKind::Data(_)) |
EventKind::Remove(_) => {
for path in event.paths {
callback(FileChangeEvent {
path,
kind: event.kind.into(),
});
}
}
_ => {} // 忽略权限变更、属性变更等
}
}
},
Config::default().with_poll_interval(Duration::from_millis(500)),
)?;
Ok(Self {
inner,
debounce_window: Duration::from_millis(100),
pending: HashMap::new(),
})
}
/// 处理去抖动后的文件事件
pub fn flush(&mut self) -> Vec<FileChangeEvent> {
let now = Instant::now();
let mut ready = Vec::new();
self.pending.retain(|path, (time, event)| {
if now.duration_since(*time) >= self.debounce_window {
ready.push(event.clone());
false // 移除已处理的事件
} else {
true // 保留仍在去抖动窗口内的事件
}
});
ready
}
}
7.2 全文搜索优化
Rust 的全文搜索使用 Tantivy 引擎(比 Lucene 轻量,纯 Rust 实现):
// crates/index/src/search.rs
use tantivy::{Index, Schema, DocAddress, LeasedItem};
use tantivy::collector::TopDocs;
use tantivy::query::QueryParser;
pub struct VaultSearchEngine {
index: Index,
schema: Schema,
query_parser: QueryParser,
}
impl VaultSearchEngine {
pub fn new() -> Result<Self, VaultError> {
let mut schema_builder = Schema::builder();
// 定义索引字段
let title = schema_builder.add_text_field("title", tantivy::schema::TEXT | tantivy::schema::STORED);
let content = schema_builder.add_text_field("content", tantivy::schema::TEXT);
let tags = schema_builder.add_text_field("tags", tantivy::schema::TEXT);
let path = schema_builder.add_text_field("path", tantivy::schema::STORED);
let schema = schema_builder.build();
let index = Index::create_in_ram(schema.clone());
let query_parser = QueryParser::for_index(
&index,
vec![title, content, tags],
);
Ok(Self { index, schema, query_parser })
}
/// 执行搜索查询
pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>, VaultError> {
let query = self.query_parser.parse_query(query)?;
let reader = self.index.reader()?;
let searcher = reader.searcher();
let top_docs = searcher.search(&query, &TopDocs::with_limit(limit))?;
let hits: Vec<SearchHit> = top_docs
.into_iter()
.map(|(score, doc_addr)| {
let doc: TantivyDocument = searcher.doc(doc_addr).unwrap();
SearchHit {
score,
title: doc.get_first(title).and_then(|v| v.as_text()).unwrap_or("").to_string(),
path: doc.get_first(path).and_then(|v| v.as_text()).unwrap_or("").to_string(),
}
})
.collect();
Ok(hits)
}
}
7.3 大文档的虚拟滚动
编辑器在加载 10000+ 行的 Markdown 时,如果不做虚拟滚动,DOM 节点数会爆炸。NeverWrite 使用 CodeMirror 6 的内置虚拟渲染——只渲染可视区域附近的行:
// 编辑器初始化配置
import { EditorView, keymap, lineNumbers, highlightActiveLine }
from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
const editorState = EditorState.create({
doc: content,
extensions: [
lineNumbers(),
highlightActiveLine(),
markdown({ base: markdownLanguage }),
// 虚拟滚动自动启用,无需额外配置
// CodeMirror 6 默认只渲染可视区域 ± 500 行
EditorView.lineWrapping,
// 自定义 Diff 高亮装饰器
diffHighlightExtension,
// 键盘快捷键
keymap.of([
{ key: 'Mod-Shift-A', run: acceptCurrentDiff },
{ key: 'Mod-Shift-R', run: rejectCurrentDiff },
{ key: 'Mod-p', run: openCommandPalette },
]),
],
});
const editorView = new EditorView({
state: editorState,
parent: document.getElementById('editor-container')!,
});
八、AI 运行时架构:多模型并行与 ACP 协议
8.1 AI Provider 抽象层
NeverWrite 内置四种 AI 运行时,通过统一的 Provider 接口抽象:
// AI Provider 接口定义
interface AIProvider {
name: string;
model: string;
// 流式生成
generate(
prompt: string,
options: GenerateOptions
): AsyncGenerator<GenerateChunk>;
// 编辑建议
suggestEdits(
content: string,
instruction: string,
context?: EditContext
): Promise<EditSuggestion>;
// 健康检查
isAvailable(): Promise<boolean>;
}
// Provider 注册表
class AIProviderRegistry {
private providers: Map<string, AIProvider> = new Map();
register(provider: AIProvider): void {
this.providers.set(provider.name, provider);
}
get(name: string): AIProvider | undefined {
return this.providers.get(name);
}
async getAvailable(): Promise<AIProvider[]> {
const results = await Promise.allSettled(
Array.from(this.providers.values()).map(async (p) => ({
provider: p,
available: await p.isAvailable(),
}))
);
return results
.filter((r): r is PromiseFulfilledResult<{provider: AIProvider; available: boolean}> =>
r.status === 'fulfilled' && r.value.available)
.map(r => r.value.provider);
}
}
8.2 ACP 协议集成
NeverWrite 使用 ACP(Agent Communication Protocol)与 AI 运行时通信。这是一种标准化的 Agent 交互协议,让不同的 AI 模型可以用统一的方式与编辑器交互:
// ACP 消息格式
interface ACPMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: ACPContent[];
metadata?: Record<string, unknown>;
}
type ACPContent =
| { type: 'text'; text: string }
| { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
| { type: 'tool_result'; tool_use_id: string; content: string; is_error?: boolean };
// ACP 客户端
class ACPClient {
private endpoint: string;
private authToken?: string;
constructor(config: ACPConfig) {
this.endpoint = config.endpoint;
this.authToken = config.authToken;
}
async *stream(messages: ACPMessage[]): AsyncGenerator<ACPChunk> {
const response = await fetch(this.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(this.authToken ? { 'Authorization': `Bearer ${this.authToken}` } : {}),
},
body: JSON.stringify({ messages, stream: true }),
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n').filter(l => l.startsWith('data: '));
for (const line of lines) {
const data = JSON.parse(line.slice(6));
yield data;
}
}
}
}
8.3 多模型协作的编辑流程
当用户让 AI 修改一段文字时,背后发生的事情比你想的复杂:
用户指令:"优化这段 API 文档的表达"
│
├── 1. Codex(快速响应):生成初版修改建议
│ ↓
├── 2. Claude(精细修改):对初版建议进行润色
│ ↓
├── 3. Diff 引擎:计算原始文本和修改后文本的差异
│ ↓
└── 4. 审阅面板:呈现差异,等待用户决策
每个模型有不同的特长——Codex 速度快,适合快速迭代;Claude 文笔好,适合精细修改。NeverWrite 可以串联使用,也可以让用户选择。
九、实战:从零搭建 NeverWrite 开发环境
说了这么多架构,来点实际的。以下是完整的开发环境搭建和调试流程。
9.1 环境准备
# 1. 安装 Rust(最新稳定版)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env
rustc --version # 确认 ≥ 1.85
# 2. 安装 Node.js 22+(推荐用 nvm)
nvm install 22
nvm use 22
node --version # 确认 v22.x
# 3. 安装 pnpm(固定版本 10.33.0,用于 Web Clipper)
npm install -g pnpm@10.33.0
pnpm --version
# 4. 安装 wasm-pack(用于 Rust → WASM 编译)
cargo install wasm-pack
# 5. 克隆项目
git clone https://github.com/neverwrite/neverwrite.git
cd neverwrite
9.2 桌面端启动
# 进入桌面端目录
cd apps/desktop
# 安装依赖
npm install
# 启动开发模式
# 这会同时启动:
# - Vite 开发服务器(渲染进程热更新)
# - Electron 主进程(自动重载)
npm run dev
启动后你会看到两个进程:
- Vite dev server:跑在
http://localhost:5173,负责渲染进程的热更新 - Electron main process:负责窗口管理、文件系统操作、IPC 通信
9.3 Web Clipper 启动
# 另开一个终端
cd apps/web-clipper
# 安装依赖
pnpm install
# 启动开发模式
pnpm dev
这会在 Chrome 中自动加载插件。如果要手动安装到 Firefox:
# 构建 Firefox 版本
pnpm build
# 插件文件在:
# apps/web-clipper/dist/firefox-mv3/
# 在 Firefox 中打开 about:debugging → "临时载入附加组件" → 选择 manifest.json
9.4 Rust Crate 测试
# 在项目根目录
cargo test
# 只测试 diff crate
cargo test -p neverwrite-diff
# 查看测试覆盖率
cargo tarpaulin -p neverwrite-diff
9.5 WASM 编译与调试
# 编译 diff crate 为 WASM
cd crates/diff
wasm-pack build --target web --out-dir pkg
# 编译后在 apps/desktop 中可以直接 import
# import { compute_diff } from '@neverwrite/diff-wasm';
9.6 环境变量配置
# AI 运行时路径覆盖(开发时用)
export NEVERWRITE_CODEX_ACP_BIN=/path/to/codex
export NEVERWRITE_CLAUDE_ACP_BIN=/path/to/claude
export NEVERWRITE_GEMINI_ACP_BIN=/path/to/gemini
# 调试模式
export NEVERWRITE_DEBUG=1
export NEVERWRITE_LOG_LEVEL=trace
十、与其他 Markdown 工具的对比分析
| 特性 | NeverWrite | Obsidian | Typora | VS Code + 插件 |
|---|---|---|---|---|
| 多窗格 | ✅ 原生支持 | ✅ 标签页 | ❌ 单窗口 | ✅ 编辑器组 |
| AI 编辑 | ✅ 4 种运行时 | 🔸 社区插件 | 🔸 有限 | 🔸 Copilot |
| AI 可审阅 | ✅ 核心功能 | ❌ 无 | ❌ 无 | 🔸 Git diff |
| 全格式 | ✅ MD/CSV/PDF/图 | 🔸 有限 | 🔸 MD only | ✅ 丰富 |
| 反向链接 | ✅ Rust 引擎 | ✅ JS 引擎 | ❌ 无 | 🔸 插件 |
| 图谱视图 | ✅ 2D/3D | ✅ 2D | ❌ 无 | 🔸 插件 |
| Web Clipper | ✅ 原生 | 🔸 社区插件 | ❌ 无 | 🔸 插件 |
| 开源 | ✅ Apache-2.0 | ❌ 闭源核心 | ❌ 闭源 | ✅ MIT |
| 底层语言 | Electron + Rust | Electron + JS | Electron + JS | Electron + JS |
| 大文件性能 | ✅ Rust 加速 | 🔸 一般 | 🔸 一般 | 🔸 一般 |
| Linux 支持 | 🔸 不完善 | ✅ 良好 | ✅ 良好 | ✅ 良好 |
| 价格 | 免费 | 免费/付费 | 付费 | 免费 |
核心差异总结:
AI 可审阅是杀手锏:Obsidian 的 AI 插件、VS Code 的 Copilot,都是"AI 改了就改了"。NeverWrite 是唯一把审阅机制做进核心的产品。
Rust 底层是性能保障:大知识库(1万+ 文件)下的文件监控、索引、搜索,Rust 实现比 JS 实现快一个数量级。
全格式支持减少工具切换:Obsidian 专注 Markdown,Typora 也专注 Markdown。NeverWrite 的"一个工作区管理所有格式"是差异化定位。
Linux 支持是短板:这也是项目目前最需要社区帮助的地方。
十一、踩坑实录与解决方案
11.1 Electron + Rust WASM 的内存泄漏
问题:3D 图谱视图在大 vault 下使用后,内存持续增长不释放。
根因:Three.js 的场景对象在组件卸载时没有完全释放,而 WASM 的线性内存不会自动收缩。
解决方案:
// 在 3D 图谱组件中添加清理逻辑
useEffect(() => {
const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer({ antialias: true });
return () => {
// 清理 Three.js 资源
scene.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.geometry.dispose();
if (Array.isArray(object.material)) {
object.material.forEach(m => m.dispose());
} else {
object.material.dispose();
}
}
});
renderer.dispose();
renderer.forceContextLoss();
// 清理 WASM 内存
if (wasmDiffEngine) {
wasmDiffEngine.free(); // 手动释放 WASM 线性内存
}
};
}, []);
11.2 pnpm 版本锁定问题
问题:Web Clipper 的 packageManager 字段要求 pnpm 10.33.0,但系统可能安装了其他版本。
解决方案:使用 corepack 启用 pnpm:
corepack enable
corepack prepare pnpm@10.33.0 --activate
# 如果 corepack 不可用,手动安装
npm install -g pnpm@10.33.0
11.3 macOS 上的文件监控权限
问题:macOS 的 App Sandbox 限制了对某些目录的文件监控。
解决方案:在 electron-builder.yml 中声明文件访问权限:
mac:
entitlements: "resources/entitlements.mac.plist"
entitlementsInherit: "resources/entitlements.mac.plist"
extendInfo:
NSDocumentsFolderUsageDescription: "NeverWrite needs access to manage your Markdown vault."
NSDesktopFolderUsageDescription: "NeverWrite needs access to save clipped content."
11.4 WASM 的跨域问题
问题:开发模式下,Vite dev server 和 WASM 文件不在同源,导致 SharedArrayBuffer 不可用。
解决方案:在 Vite 配置中添加跨域头:
// apps/desktop/vite.config.ts
export default defineConfig({
server: {
headers: {
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Opener-Policy': 'same-origin',
},
},
});
十二、二次开发:基于 NeverWrite 打造你自己的知识工具
12.1 添加自定义 AI Provider
// 自定义 Ollama Provider
class OllamaProvider implements AIProvider {
name = 'ollama';
model = 'llama3';
private endpoint = 'http://localhost:11434';
async *generate(prompt: string, options: GenerateOptions): AsyncGenerator<GenerateChunk> {
const response = await fetch(`${this.endpoint}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.model,
prompt,
stream: true,
}),
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = JSON.parse(decoder.decode(value));
yield {
type: 'text',
text: chunk.response,
done: chunk.done,
};
}
}
async suggestEdits(content: string, instruction: string): Promise<EditSuggestion> {
const prompt = `You are an expert editor. Modify the following text according to the instruction.
Instruction: ${instruction}
Original text:
${content}
Return ONLY the modified text, nothing else.`;
let result = '';
for await (const chunk of this.generate(prompt, {})) {
result += chunk.text;
}
return {
originalContent: content,
suggestedContent: result,
instruction,
};
}
async isAvailable(): Promise<boolean> {
try {
const res = await fetch(`${this.endpoint}/api/tags`);
return res.ok;
} catch {
return false;
}
}
}
// 注册
const registry = new AIProviderRegistry();
registry.register(new OllamaProvider());
12.2 自定义导出格式
// 自定义 Markdown → Notion 导出
class NotionExporter {
async export(vault: Vault, outputPath: string): Promise<void> {
const files = vault.getAllMarkdownFiles();
for (const file of files) {
const notionBlocks = this.markdownToNotionBlocks(file.content);
await this.writeNotionPage(notionBlocks, file.title);
}
}
private markdownToNotionBlocks(md: string): NotionBlock[] {
const blocks: NotionBlock[] = [];
const lines = md.split('\n');
for (const line of lines) {
if (line.startsWith('# ')) {
blocks.push({
object: 'block',
type: 'heading_1',
heading_1: { rich_text: [{ type: 'text', text: { content: line.slice(2) } }] },
});
} else if (line.startsWith('```')) {
// 代码块处理
blocks.push({
object: 'block',
type: 'code',
code: { rich_text: [{ type: 'text', text: { content: '/* code */' } }], language: 'plain text' },
});
}
// ... 更多类型
}
return blocks;
}
}
十三、总结与展望
NeverWrite 的核心价值
AI 可审阅是范式创新:它改变了"AI 编辑"的默认模式——从"AI 替你做决定"变成"AI 给你建议,你来决定"。这个思路不仅适用于 Markdown 编辑器,对所有 AI 辅助工具都有借鉴意义。
Electron + Rust 的混合架构是务实选择:不是每件事都需要从头用 Rust 重写。Electron 处理 UI 和生态复用,Rust 处理性能瓶颈,各司其职。这种"在正确的地方用正确的工具"的哲学,比追求架构纯洁性更重要。
开源 + 全格式是差异化路径:Obsidian 闭源核心 + 付费模式,Typora 闭源 + 付费,VS Code 开源但 AI 功能依赖 Copilot 生态。NeverWrite 开源免费 + 全格式 + AI 可审阅,找到了自己的生态位。
当前短板与期待
| 短板 | 影响 | 预期改善时间 |
|---|---|---|
| Linux 适配不完善 | Linux 用户无法正常使用 | 需要社区贡献 |
| 3D 图谱内存泄漏 | 大 vault 下需重启 | 下个版本修复 |
| 0.2.x 版本不稳定 | 生产环境慎用 | 等待 1.0 |
| 纯新手安装门槛 | 需要 3 个开发工具 | 计划提供预编译包 |
| AI Provider 仅捆绑 2 个 | Gemini/Kilo 需手动配置 | 后续版本捆绑 |
我的判断
NeverWrite 目前还是个"有潜力但不成熟"的项目。它的核心设计思路(AI 可审阅 + 多窗格 + Electron+Rust 混合架构)是正确的,但 0.2.x 的版本号意味着它还在快速迭代中,不适合作为主力工具使用。
但如果你是一个关注"人机协作编辑"方向的开发者,这个项目值得你深入研究它的源码。 尤其是:
crates/diff/:Rust → WASM 的 Diff 引擎实现crates/vault/:高性能文件监控和索引src/renderer/:AI 审阅 UI 的 React 实现
这些模块的设计和代码质量,都值得学习和借鉴。而且作为 Apache-2.0 开源项目,你可以直接基于它做二次开发。
在这个 AI 无处不在的时代,让 AI 成为助手,而不是替代者——NeverWrite 给出的这个答案,或许比它作为一个编辑器本身更有价值。
项目地址:https://github.com/neverwrite/neverwrite
协议:Apache-2.0
当前版本:0.2.3
技术栈:Electron 41 + React 19 + Rust 2021 + WASM + Three.js + Tantivy