编程 NeverWrite 深度实战:用 Electron+Rust 打造 AI 可审阅的多窗格 Markdown 工作区——从混合架构到 Diff 引擎的全链路解析

2026-05-07 02:09:42 +0800 CST views 8

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 的理由很实际:

  1. AI 运行时嵌入:NeverWrite 需要捆绑 Codex 和 Claude 的嵌入式运行时。Electron 的 Node.js 环境天然支持这种"主进程跑重活"的模式,而 Tauri 的 Rust 后端要嵌入 Node.js 运行时就非常别扭——你等于在 Rust 里再跑一个 JS 引擎,多此一举。

  2. React 生态:NeverWrite 的 UI 层是 React 19,整个组件生态(Excalidraw、代码编辑器、图谱视图)都依赖 Web 生态。Electron 对 Web 标准的支持最完整,不用担心 WebView 的兼容性地雷。

  3. Web Clipper 复用:浏览器插件(WXT + React)和桌面端共享大量 UI 代码,Electron 让这种复用毫无摩擦。

但 Electron 的问题也明显:

  • 内存占用大(基础就要 200MB+)
  • 文件系统操作慢(Node.js fs 在大量文件时性能不佳)
  • 原生能力弱(窗口管理、系统级操作都需要额外方案)

所以 Rust 补位了:

  • vault crate:文件系统扫描、解析、监控,Rust 原生文件 I/O 比 Node.js 快 5-10 倍
  • diff crate → WASM:差异引擎编译成 WebAssembly,在渲染进程中以接近原生的速度运行
  • index crate:链接解析、全文搜索原语,处理大型知识库时不会被 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 独立测试
  • diff crate 可以单独编译为 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; // 二叉树布局描述
}

关键设计点:

  1. 二叉树布局:窗格的布局用二叉树描述,每个叶子节点是一个窗格,内部节点记录分割方向和比例。这使得窗格的拆分、合并、调整大小都可以用递归算法高效处理。

  2. Detached 窗口:任何窗格都可以"撕裂"成独立窗口(类似浏览器把 Tab 拖出去变成新窗口),但数据仍然共享。实现方式是通过 Electron 的 BrowserWindow 创建新窗口,共享同一个 IPC Bridge

  3. 命令面板:快捷键驱动的操作入口,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,让用户能看清楚每一处改动。这个操作有几个苛刻要求:

  1. 实时性:AI 流式输出,Diff 计算不能成为瓶颈
  2. 精确性:必须是字符级 Diff,不是行级 Diff,否则修改一行中间某几个字就看不出区别
  3. 可逆性:每个 Diff 操作必须能精确回退
  4. 内存效率:大文档(几万行 Markdown)的 Diff 不能吃光内存

JavaScript 的 diff 库(如 diff-match-patchjsdiff)在几千行以内的文档表现尚可,但到了大文档,性能和内存都扛不住。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 审阅流程的完整链路

让我用一个具体场景说明整个链路:

  1. 用户写了一段 Markdown,让 AI 优化措辞
  2. AI 在后台生成修改后的文本
  3. Rust Diff 引擎计算原始文本和 AI 修改后文本的差异
  4. 差异结果序列化后传给 React
  5. React 渲染审阅面板,绿色标记新增、红色标记删除
  6. 用户逐个审查,点"接受"或"拒绝"
  7. 接受的修改通过 applyDiff 写入编辑器,拒绝的修改丢弃
  8. 最终结果保存到文件

这个流程的关键洞察是: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

启动后你会看到两个进程:

  1. Vite dev server:跑在 http://localhost:5173,负责渲染进程的热更新
  2. 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 工具的对比分析

特性NeverWriteObsidianTyporaVS Code + 插件
多窗格✅ 原生支持✅ 标签页❌ 单窗口✅ 编辑器组
AI 编辑✅ 4 种运行时🔸 社区插件🔸 有限🔸 Copilot
AI 可审阅✅ 核心功能❌ 无❌ 无🔸 Git diff
全格式✅ MD/CSV/PDF/图🔸 有限🔸 MD only✅ 丰富
反向链接✅ Rust 引擎✅ JS 引擎❌ 无🔸 插件
图谱视图✅ 2D/3D✅ 2D❌ 无🔸 插件
Web Clipper✅ 原生🔸 社区插件❌ 无🔸 插件
开源✅ Apache-2.0❌ 闭源核心❌ 闭源✅ MIT
底层语言Electron + RustElectron + JSElectron + JSElectron + JS
大文件性能✅ Rust 加速🔸 一般🔸 一般🔸 一般
Linux 支持🔸 不完善✅ 良好✅ 良好✅ 良好
价格免费免费/付费付费免费

核心差异总结:

  1. AI 可审阅是杀手锏:Obsidian 的 AI 插件、VS Code 的 Copilot,都是"AI 改了就改了"。NeverWrite 是唯一把审阅机制做进核心的产品。

  2. Rust 底层是性能保障:大知识库(1万+ 文件)下的文件监控、索引、搜索,Rust 实现比 JS 实现快一个数量级。

  3. 全格式支持减少工具切换:Obsidian 专注 Markdown,Typora 也专注 Markdown。NeverWrite 的"一个工作区管理所有格式"是差异化定位。

  4. 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 的核心价值

  1. AI 可审阅是范式创新:它改变了"AI 编辑"的默认模式——从"AI 替你做决定"变成"AI 给你建议,你来决定"。这个思路不仅适用于 Markdown 编辑器,对所有 AI 辅助工具都有借鉴意义。

  2. Electron + Rust 的混合架构是务实选择:不是每件事都需要从头用 Rust 重写。Electron 处理 UI 和生态复用,Rust 处理性能瓶颈,各司其职。这种"在正确的地方用正确的工具"的哲学,比追求架构纯洁性更重要。

  3. 开源 + 全格式是差异化路径: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

推荐文章

Nginx 性能优化有这篇就够了!
2024-11-19 01:57:41 +0800 CST
使用Vue 3实现无刷新数据加载
2024-11-18 17:48:20 +0800 CST
HTML + CSS 实现微信钱包界面
2024-11-18 14:59:25 +0800 CST
html文本加载动画
2024-11-19 06:24:21 +0800 CST
Golang 中应该知道的 defer 知识
2024-11-18 13:18:56 +0800 CST
#免密码登录服务器
2024-11-19 04:29:52 +0800 CST
为什么要放弃UUID作为MySQL主键?
2024-11-18 23:33:07 +0800 CST
css模拟了MacBook的外观
2024-11-18 14:07:40 +0800 CST
Gin 框架的中间件 代码压缩
2024-11-19 08:23:48 +0800 CST
程序员茄子在线接单