milkup:桌面端 markdown AI续写和即时渲染

Hi,朋友们好,我是德莱厄斯,前段时间给大家带来一个桌面端的开源 markdown 编辑器,当时扬言要干翻 typora 的那个,你还有印象吗? 原文是:干翻 Typora!MilkUp:完全免费的桌面端 Markdown 编辑器!,这篇文章共曝光了 16 万次,有 12000+ 人围观,在社区内收获了小范围的用户,目前它的 github star 已有 600+。

在此期间,我们 团队(Auto-Plugin)的每位成员都为 milkup 添砖加瓦,填缺补漏,milkup 日渐成为一个几乎稳定的编辑器。

现在的 milkup 几乎可以做到媲美 typora 的编辑体验,甚至更上一层楼!

接下来,由我的手下:Claude Code 为大家带来最新的功能支持介绍(主要是即时渲染模式、AI续写部分功能),因为近期大部分功能都是它写的。

注意:本文 AI 含量 100%

前言

Hi,我是 Claude Code,Anthropic 官方的 AI 编程助手。很高兴能与德莱厄斯共同完成 milkup 新版的开发,以及这篇文章的撰写。

在这次合作中,我们为 milkup 带来了两个重要的功能更新:即时渲染模式(feat-ir)AI 续写功能(feat-ai) 。这两个功能的加入,让 milkup 在 Markdown 编辑器领域迈出了重要的一步,不仅在编辑体验上向 Typora 看齐,更在智能化方向上实现了突破。

本文将从需求背景、功能特性、技术实现、对比分析等多个维度,详细介绍这两个功能的设计思路和实现细节。希望能为正在开发或使用 Markdown 编辑器的开发者和用户提供一些参考和启发。


一、项目背景与需求分析

1.1 milkup 项目简介

milkup 是一个现代化的桌面端 Markdown 编辑器,基于 Electron + Vue 3 + TypeScript 构建。项目的核心目标是提供一个功能强大、体验优雅、性能出色的 Markdown 编辑环境。

核心技术栈:

  • 前端框架:Vue 3 + TypeScript
  • 编辑器核心:Milkdown(基于 ProseMirror)+ Crepe
  • 源码编辑器:CodeMirror 6
  • 桌面框架:Electron
  • 构建工具:Vite + esbuild
  • 包管理器:pnpm

1.2 为什么需要即时渲染模式?

在 Markdown 编辑器的发展历程中,编辑模式经历了几个阶段:

  1. 分栏预览模式(如早期的 MarkdownPad):左侧源码,右侧预览,割裂感强
  2. 纯所见即所得模式(如 Notion):完全隐藏语法,失去了 Markdown 的简洁性
  3. 即时渲染模式(如 Typora):平衡了语法可见性和渲染效果

Typora 的成功证明了即时渲染模式的优越性:

  • 写作流畅性:不需要在源码和预览之间切换视线
  • 语法可控性:需要时可以看到和编辑原始语法
  • 视觉舒适性:大部分时间看到的是渲染后的效果

然而,Typora 是闭源软件,且已经停止免费更新。市面上缺少一个开源、现代化、可扩展的即时渲染编辑器。这就是 feat-ir 分支的诞生背景。

1.3 为什么需要 AI 续写功能?

随着 AI 技术的发展,智能写作辅助已经成为现代编辑器的标配:

  • CursorGitHub Copilot 在代码编辑领域大放异彩
  • Notion AI飞书妙记 在文档编辑领域提供智能补全
  • Grammarly 在英文写作领域提供语法建议

但在 Markdown 编辑器领域,AI 集成还相对滞后。大多数 Markdown 编辑器要么完全不支持 AI,要么只是简单地调用 API 生成文本,缺乏对 Markdown 结构的理解。

feat-ai 分支的目标是:

  • 结构化理解:理解文档的标题层级、上下文关系
  • 多提供商支持:支持 OpenAI、Claude、Gemini、Ollama 等多种 AI 服务
  • 无缝集成:像代码补全一样自然,不打断写作流程
  • 本地优先:支持 Ollama 等本地模型,保护隐私

二、feat-ir:即时渲染模式详解

2.1 功能特性

feat-ir 分支实现了类似 Typora 的即时渲染模式,核心特性包括:

2.1.1 智能源码显示

当光标移动到 Markdown 语法元素时,自动显示该元素的源码语法:

  • 行内标记(Marks)

  • **加粗** → 光标进入时显示前后的 **

  • *斜体* → 显示前后的 *

  • 代码 → 显示前后的 `````

  • ~~删除线~~ → 显示前后的 ~~

  • [链接文本](URL) → 显示 []() 结构

  • 块级元素(Nodes)

  • 标题:显示对应数量的 # 符号(如 #表示一级标题)

  • 图片:显示 ![alt](milkup:///RDpcb3BlbnNvdXJjZVxtaWxrdXBcbWlsa3Vw77ya5qGM6Z2i56uvIG1hcmtkb3duIEFJ57ut5YaZ5ZKM5Y2z5pe25riy5p+TLm1k/src) 完整语法

2.1.2 即时编辑能力

不仅可以看到源码,还可以直接编辑:

  • 链接 URL 编辑:点击 URL 部分可以直接修改链接地址
  • 图片属性编辑:可以修改图片的 alt 文本和 src 路径
  • 实时生效:编辑完成后按 Enter 或失焦,修改立即生效

2.1.3 键盘导航

提供流畅的键盘操作体验:

  • ArrowLeft/Right:在源码编辑器和渲染视图之间切换焦点
  • Enter:提交编辑并返回渲染视图
  • 自动跳出:光标移出语法元素时,自动隐藏源码

2.2 实现原理

2.2.1 ProseMirror 装饰器系统

即时渲染的核心是 ProseMirror 的 Decoration(装饰器) 系统。装饰器允许我们在不修改文档结构的情况下,在视图层添加额外的 DOM 元素。

复制代码
// 核心插件结构
export const sourceOnFocusPlugin = $prose((ctx) => {
return new Plugin({
state: {
init() {
return DecorationSet.empty;
},
apply(tr, oldState) {
const { selection } = tr;
const decorations: Decoration[] = [];

// 根据光标位置动态生成装饰器
// ...

return DecorationSet.create(tr.doc, decorations);
}
},
props: {
decorations(state) {
return this.getState(state);
}
}
});
});

装饰器的优势:

  • 非侵入性:不修改文档的实际内容
  • 高性能:只在视图层渲染,不影响数据层
  • 灵活性:可以动态添加、移除装饰器

2.2.2 Marks 处理策略

对于行内标记(如加粗、斜体、链接),我们需要在文本前后添加语法符号:

实现思路:

  1. 遍历光标位置的 Marks:获取当前光标所在位置的所有标记
  2. 计算标记范围:找到每个标记的起始和结束位置
  3. 创建装饰器:在起始位置前和结束位置后插入语法符号

以链接为例:

复制代码
// 处理链接标记
if (mark.type.name === "link") {
const href = mark.attrs.href || "";

// 创建前缀 [
const prefixSpan = document.createElement("span");
prefixSpan.className = "md-source";
prefixSpan.textContent = "[";

// 创建后缀 ](URL)
const suffixSpan = document.createElement("span");
suffixSpan.className = "md-source";
suffixSpan.textContent = "](";

// 创建可编辑的 URL 部分
const urlSpan = document.createElement("span");
urlSpan.className = "md-source-url-editable";
urlSpan.contentEditable = "true";
urlSpan.textContent = href;

// 监听编辑事件
urlSpan.addEventListener("blur", () => {
const newHref = urlSpan.textContent || "";
if (newHref !== href) {
// 更新文档中的链接
view.dispatch(
view.state.tr
.removeMark(start, end, mark.type)
.addMark(start, end, mark.type.create({ href: newHref }))
);
}
});

suffixSpan.appendChild(urlSpan);

const closingSpan = document.createElement("span");
closingSpan.className = "md-source";
closingSpan.textContent = ")";
suffixSpan.appendChild(closingSpan);

// 添加装饰器
decorations.push(
Decoration.widget(start, () => prefixSpan),
Decoration.widget(end, () => suffixSpan)
);
}

关键点:

  • 使用 contentEditable="true" 实现即时编辑
  • 通过 blur 事件监听编辑完成
  • 使用 ProseMirror 的 transaction 更新文档

2.2.3 Nodes 处理策略

对于块级元素(如标题、图片),处理方式略有不同:

标题处理:

复制代码
if (node.type.name === "heading") {
const level = node.attrs.level || 1;
const prefix = "#".repeat(level) + " ";

const span = document.createElement("span");
span.className = "md-source";
span.textContent = prefix;

decorations.push(
Decoration.widget($from.start(), () => span)
);
}

图片处理:

图片的处理更复杂,因为需要同时编辑 alt 文本和 src 路径:

复制代码
if (node.type.name === "image") {
const { src, alt } = node.attrs;

// 创建 ![
const prefixSpan = document.createElement("span");
prefixSpan.className = "md-source";
prefixSpan.textContent = "![";

// 创建可编辑的 alt
const altSpan = document.createElement("span");
altSpan.className = "md-source-editable";
altSpan.contentEditable = "true";
altSpan.textContent = alt || "";

// 创建 ](milkup:///RDpcb3BlbnNvdXJjZVxtaWxrdXBcbWlsa3Vw77ya5qGM6Z2i56uvIG1hcmtkb3duIEFJ57ut5YaZ5ZKM5Y2z5pe25riy5p+TLm1k/
%20%20const%20middleSpan%20=%20document.createElement("span");
middleSpan.className = "md-source";
middleSpan.textContent = "](";

// 创建可编辑的 src
const srcSpan = document.createElement("span");
srcSpan.className = "md-source-url-editable";
srcSpan.contentEditable = "true";
srcSpan.textContent = src || "";

// 创建 )
const suffixSpan = document.createElement("span");
suffixSpan.className = "md-source";
suffixSpan.textContent = ")";

// 组合所有元素
const container = document.createElement("div");
container.append(prefixSpan, altSpan, middleSpan, srcSpan, suffixSpan);

decorations.push(
Decoration.widget(pos, () => container)
);
}

2.2.4 样式设计

为了让源码显示既清晰又不突兀,我们设计了专门的样式:

复制代码
// 源码基础样式
.md-source {
color: var(--text-color-4); // 使用较浅的颜色
font-family: var(--milkup-font-code); // 等宽字体
opacity: 0.6; // 半透明
background: var(--background-color-2); // 浅色背景
padding: 0 2px;
border-radius: 2px;
font-size: 0.9em;
}

// 可编辑的 URL 样式
.md-source-url-editable {
display: inline-block;
outline: none;
cursor: text;
border-bottom: 1px dashed var(--border-color); // 虚线下划线提示可编辑
min-width: 50px;

&:hover {
background: var(--background-color-3);
}

&:focus {
border-bottom-style: solid;
background: var(--background-color-3);
}
}

设计原则:

  • 低对比度:使用半透明和浅色,不干扰阅读
  • 等宽字体:保持代码感,与正文区分
  • 交互提示:可编辑元素有明确的视觉反馈

2.3 技术挑战与解决方案

2.3.1 光标跳出问题

问题 :当用户在可编辑的 URL 中按方向键时,光标可能被困在 contentEditable 元素中,无法跳出。

解决方案:监听键盘事件,手动控制光标移动:

复制代码
urlSpan.addEventListener("keydown", (e) => {
if (e.key === "ArrowLeft" && urlSpan.selectionStart === 0) {
// 光标在最左侧,按左键跳出
e.preventDefault();
const pos = view.posAtDOM(urlSpan, 0);
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, pos)));
view.focus();
} else if (e.key === "ArrowRight" && urlSpan.selectionEnd === urlSpan.textContent.length) {
// 光标在最右侧,按右键跳出
e.preventDefault();
const pos = view.posAtDOM(urlSpan, urlSpan.textContent.length);
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, pos + 1)));
view.focus();
} else if (e.key === "Enter") {
// 按 Enter 提交并跳出
e.preventDefault();
urlSpan.blur();
}
});

2.3.2 装饰器性能优化

问题:每次光标移动都重新计算所有装饰器,可能导致性能问题。

解决方案

  1. 增量更新:只在光标位置变化时更新装饰器

  2. 缓存机制:缓存上一次的装饰器集合,避免重复计算

  3. 范围限制:只处理光标附近的元素,不遍历整个文档

    apply(tr, oldState) {
    // 如果光标位置没变,直接返回旧状态
    if (!tr.docChanged && !tr.selectionSet) {
    return oldState;
    }

    // 只处理光标附近 1000 字符范围内的元素
    const { from, to } = tr.selection;
    const rangeStart = Math.max(0, from - 500);
    const rangeEnd = Math.min(tr.doc.content.size, to + 500);

    // 只在这个范围内查找需要装饰的元素
    // ...
    }

2.3.3 与其他插件的兼容性

问题:装饰器可能与其他插件(如拼写检查、语法高亮)冲突。

解决方案

  1. 装饰器优先级 :使用 ProseMirror 的 spec.key 设置优先级
  2. 避免重叠:检测装饰器是否重叠,避免覆盖
  3. 事件隔离 :使用 stopPropagation 防止事件冒泡

三、feat-ai:AI 续写功能详解

3.1 功能特性

feat-ai 分支为 milkup 带来了智能续写能力,让 AI 成为你的写作助手。

3.1.1 多 AI 提供商支持

支持主流的 AI 服务提供商:

  • OpenAI:GPT-3.5-turbo、GPT-4、GPT-4-turbo
  • Anthropic:Claude 3 Haiku、Claude 3 Sonnet、Claude 3 Opus
  • Google:Gemini Pro、Gemini Pro Vision
  • Ollama:支持本地运行的开源模型(Llama 2、Mistral、Qwen 等)
  • 自定义 API:兼容 OpenAI API 格式的任何服务

配置界面:

用户可以在设置中轻松配置 AI 服务:

  • 选择提供商
  • 输入 API Key
  • 设置 Base URL(用于代理或自定义服务)
  • 选择模型
  • 调整温度参数(控制创造性)
  • 设置防抖延迟(控制触发频率)

3.1.2 结构化上下文理解

AI 续写不是简单地续接文本,而是理解文档的结构:

提取的上下文信息:

  1. 文件名:了解文档主题
  2. 标题层级:理解文档结构和当前章节
  3. 前文内容:分析写作风格和上下文
  4. 光标位置:确定续写的起点

示例:

假设你正在写一篇技术博客:

复制代码
# Vue 3 组合式 API 最佳实践

## 一、为什么选择组合式 API

组合式 API 是 Vue 3 引入的新特性,它提供了更灵活的代码组织方式。

## 二、核心概念

### 2.1 响应式系统

Vue 3 的响应式系统基于 Proxy,相比 Vue 2 的 Object.defineProperty 有以下优势:
- 可以检测属性的添加和删除
- 可以检测数组索引和长度的变化
- [光标在这里]

AI 会理解:

  • 这是一篇关于 Vue 3 的技术文章
  • 当前在讨论响应式系统的优势
  • 前面已经列举了两个优势
  • 应该继续列举更多优势或展开说明

3.1.3 智能触发机制

防抖策略:

  • 用户停止输入后等待 1-3 秒(可配置)
  • 避免频繁调用 API,节省成本
  • 不打断用户的写作流程

触发条件:

  • 光标在段落末尾
  • 前面有足够的上下文(至少 50 个字符)
  • 不在代码块、表格等特殊区域内

取消机制:

  • 用户继续输入时,自动取消当前请求
  • 文档内容变化时,清除已显示的建议

3.1.4 优雅的 UI 集成

显示方式:

  • 续写建议以半透明文本显示在光标后
  • 使用不同的颜色和字体样式,与正文区分
  • 不占用实际的文档空间

交互方式:

  • Tab 键接受建议
  • Esc 键拒绝建议
  • 继续输入自动清除建议

视觉设计:

复制代码
.ai-completion-suggestion {
color: var(--text-color-3);
opacity: 0.5;
font-style: italic;
pointer-events: none; // 不影响鼠标交互
user-select: none; // 不可选中
}

3.2 实现原理

3.2.1 插件架构

AI 续写功能通过 ProseMirror 插件实现,核心文件位于 src/renderer/components/editor/plugins/completionPlugin.ts

插件状态管理:

复制代码
export const completionPlugin = $prose((ctx) => {
const completionKey = new PluginKey("completion");

return new Plugin({
key: completionKey,
state: {
init() {
return {
decoration: DecorationSet.empty,
suggestion: null,
loading: false
};
},
apply(tr, value) {
// 文档内容变化时清除建议
if (tr.docChanged) {
return {
decoration: DecorationSet.empty,
suggestion: null,
loading: false
};
}

// 手动更新(如 AI 返回结果)
const meta = tr.getMeta(completionKey);
if (meta) {
return meta;
}

return value;
}
},
props: {
decorations(state) {
return this.getState(state)?.decoration;
},
handleKeyDown(view, event) {
// 处理 Tab 键接受建议
if (event.key === "Tab") {
const state = this.getState(view.state);
if (state?.suggestion) {
event.preventDefault();
const tr = view.state.tr.insertText(
state.suggestion,
view.state.selection.to
);
tr.setMeta(completionKey, {
decoration: DecorationSet.empty,
suggestion: null,
loading: false
});
view.dispatch(tr);
return true;
}
}
return false;
}
}
});
});

插件状态包含三个字段:

  • decoration:用于显示建议的装饰器集合
  • suggestion:当前的建议文本
  • loading:是否正在请求 AI

3.2.2 多 AI 提供商集成

AI 服务层位于 src/renderer/services/ai.ts,通过统一的接口支持多个提供商。

服务接口设计:

复制代码
export class AIService {
static async complete(context: APIContext): Promise<CompletionResponse> {
const config = useAIConfig().config.value;

if (!config.enabled || !config.apiKey) {
throw new Error("AI 服务未配置");
}

// 根据提供商构建不同的请求
const { url, headers, body } = this.buildRequest(config, context);

// 发送请求
const response = await this.request(url, {
method: "POST",
headers,
body: JSON.stringify(body)
});

// 解析响应
return this.parseResponse(response, config.provider);
}
}

各提供商的实现差异:

  1. OpenAI / 自定义 API :使用 response_format 强制 JSON 输出

    case "openai":
    case "custom":
    return {
    url: ${config.baseUrl}/chat/completions,
    headers: {
    "Content-Type": "application/json",
    "Authorization": Bearer ${config.apiKey}
    },
    body: {
    model: config.model,
    messages: [
    { role: "system", content: SYSTEM_PROMPT },
    { role: "user", content: userMessage }
    ],
    temperature: config.temperature,
    response_format: {
    type: "json_schema",
    json_schema: {
    name: "continuation",
    schema: {
    type: "object",
    properties: {
    continuation: { type: "string" }
    },
    required: ["continuation"]
    }
    }
    }
    }
    };

  2. Anthropic (Claude) :使用 Tool Use 机制

    case "anthropic":
    return {
    url: ${config.baseUrl}/v1/messages,
    headers: {
    "Content-Type": "application/json",
    "x-api-key": config.apiKey,
    "anthropic-version": "2023-06-01"
    },
    body: {
    model: config.model,
    system: SYSTEM_PROMPT,
    messages: [{ role: "user", content: userMessage }],
    tools: [{
    name: "print_continuation",
    description: "输出续写内容",
    input_schema: {
    type: "object",
    properties: {
    continuation: { type: "string" }
    },
    required: ["continuation"]
    }
    }],
    tool_choice: { type: "tool", name: "print_continuation" }
    }
    };

  3. Google Gemini :使用 responseMimeTyperesponseSchema

    case "gemini":
    return {
    url: ${config.baseUrl}/v1beta/models/${config.model}:generateContent?key=${config.apiKey},
    headers: {
    "Content-Type": "application/json"
    },
    body: {
    contents: [{
    parts: [{ text: SYSTEM_PROMPT + "\n" + userMessage }]
    }],
    generationConfig: {
    temperature: config.temperature,
    responseMimeType: "application/json",
    responseSchema: {
    type: "OBJECT",
    properties: {
    continuation: { type: "STRING" }
    },
    required: ["continuation"]
    }
    }
    }
    };

  4. Ollama :使用 format 参数指定 JSON Schema

    case "ollama":
    return {
    url: ${config.baseUrl}/api/chat,
    headers: {
    "Content-Type": "application/json"
    },
    body: {
    model: config.model,
    messages: [
    { role: "system", content: SYSTEM_PROMPT },
    { role: "user", content: userMessage }
    ],
    format: {
    type: "object",
    properties: {
    continuation: { type: "string" }
    },
    required: ["continuation"]
    },
    stream: false,
    options: {
    temperature: config.temperature
    }
    }
    };

设计亮点:

  • 统一的接口,隐藏提供商差异
  • 充分利用各提供商的原生能力(JSON Schema、Tool Use)
  • 易于扩展,添加新提供商只需增加一个 case

3.2.3 上下文提取策略

AI 续写的质量很大程度上取决于上下文的质量。milkup 实现了智能的上下文提取策略。

提取的信息:

  1. 文件标题:从文件路径中提取

    const fileTitle = (window as any).__currentFilePath
    ? (window as any).__currentFilePath.split(/[/]/).pop()
    : "未命名文档";

  2. 前文内容:获取光标前最近 200 个字符

    const start = Math.max(0, to - 200);
    const previousContent = doc.textBetween(start, to, "\n");

  3. 标题层级:遍历文档提取所有标题

    const headers: { level: number; text: string }[] = [];
    doc.nodesBetween(0, to, (node, pos) => {
    if (node.type.name === "heading") {
    if (pos + node.nodeSize <= to) {
    headers.push({
    level: node.attrs.level,
    text: node.textContent
    });
    }
    return false;
    }
    return true;
    });

  4. 当前章节:确定当前所在的章节和子章节

    let sectionTitle = "未知";
    let subSectionTitle = "未知";

    if (headers.length > 0) {
    const lastHeader = headers[headers.length - 1];
    subSectionTitle = lastHeader.text;

    // 查找父级标题
    const parentHeader = headers
    .slice(0, -1)
    .reverse()
    .find((h) => h.level < lastHeader.level);

    if (parentHeader) {
    sectionTitle = parentHeader.text;
    }
    }

构建 Prompt:

复制代码
private static buildPrompt(context: APIContext): string {
return `上下文:
文章标题:${context.fileTitle || "未知"}
大标题:${context.sectionTitle || "未知"}
本小节标题:${context.subSectionTitle || "未知"}
前面内容(请紧密衔接):${context.previousContent}`;
}

System Prompt:

复制代码
const SYSTEM_PROMPT = `你是一个技术文档续写助手。
严格只输出以下 JSON,**不要有任何前缀、后缀、markdown、换行、解释**:

{"continuation": "接下来只写3--35个汉字的自然衔接内容"}
`;

设计理念:

  • 结构化理解:不仅提供文本,还提供文档结构
  • 精确定位:明确当前所在的章节位置
  • 长度控制:限制 3-35 个汉字,避免过度生成
  • 格式约束:强制 JSON 输出,便于解析

3.2.4 UI 显示机制

建议的显示使用 ProseMirror 的 Decoration 系统,在光标位置插入半透明的建议文本。

创建建议 Widget:

复制代码
// 创建建议元素
const widget = document.createElement("span");
widget.textContent = result.continuation;
widget.className = "ai-completion-suggestion";
widget.style.color = "var(--text-color-light, #999)";
widget.style.opacity = "0.6";
widget.style.fontStyle = "italic";
widget.style.pointerEvents = "none"; // 不影响鼠标交互
widget.style.userSelect = "none"; // 不可选中
widget.dataset.suggestion = result.continuation;

// 创建装饰器
const deco = Decoration.widget(to, widget, { side: 1 });
const decoSet = DecorationSet.create(view.state.doc, [deco]);

// 更新编辑器状态
const tr = view.state.tr.setMeta(completionKey, {
decoration: decoSet,
suggestion: result.continuation,
loading: false
});
view.dispatch(tr);

样式设计:

复制代码
.ai-completion-suggestion {
color: var(--text-color-3);
opacity: 0.5;
font-style: italic;
pointer-events: none;
user-select: none;
transition: opacity 0.2s ease;

&:hover {
opacity: 0.7;
}
}

交互设计:

  1. Tab 键接受建议

    if (event.key === "Tab") {
    const state = this.getState(view.state);
    if (state?.suggestion) {
    event.preventDefault();

    // 插入建议文本
    const tr = view.state.tr.insertText(
    state.suggestion,
    view.state.selection.to
    );

    // 清除建议
    tr.setMeta(completionKey, {
    decoration: DecorationSet.empty,
    suggestion: null,
    loading: false
    });

    view.dispatch(tr);
    return true;
    }
    }

  2. 自动清除机制

    apply(tr, value) {
    // 文档内容变化时清除建议
    if (tr.docChanged) {
    return {
    decoration: DecorationSet.empty,
    suggestion: null,
    loading: false
    };
    }

    return value;
    }

用户体验细节:

  • 半透明显示:不干扰正常阅读
  • 斜体样式:与正文区分
  • 不可交互:不影响鼠标点击和文本选择
  • 即时清除:用户继续输入时自动消失
  • 快捷接受:Tab 键一键接受

3.3 技术挑战与解决方案

3.3.1 结构化输出的挑战

问题:不同的 AI 模型对输出格式的控制能力不同,有些模型可能输出额外的文本、markdown 代码块或解释性内容。

解决方案

  1. 利用各提供商的原生能力
  • OpenAI:使用 response_format 的 JSON Schema
  • Claude:使用 Tool Use 机制
  • Gemini:使用 responseMimeTyperesponseSchema
  • Ollama:使用 format 参数
  1. 多层解析策略

    private static parseResponse(text: string): CompletionResponse {
    try {
    // 1. 尝试直接 JSON 解析
    const cleanText = text.replace(/json\n?|\n?/g, "").trim();
    const json = JSON.parse(cleanText);
    if (json.continuation) {
    return { continuation: json.continuation };
    }
    } catch (e) {
    console.warn("JSON parse failed, trying regex extraction");
    }

    // 2. 正则提取
    const match = text.match(/"continuation"\s*:\s*"([^"]+)"/);
    if (match && match[1]) {
    return { continuation: match[1] };
    }

    // 3. 兜底策略:如果文本很短且不包含 JSON 结构,直接使用
    if (text.length < 50 && !text.includes("{")) {
    return { continuation: text.trim() };
    }

    throw new Error("Failed to parse AI response");
    }

鲁棒性保证:

  • 清理 markdown 代码块标记
  • 正则表达式兜底
  • 短文本直接使用
  • 多层容错机制

3.3.2 防抖与取消机制

问题:用户输入时频繁触发 AI 请求,浪费资源且影响体验。

解决方案

  1. 防抖触发

    let debounceTimer: NodeJS.Timeout | null = null;

    view.updateState(view.state);

    // 清除旧的定时器
    if (debounceTimer) {
    clearTimeout(debounceTimer);
    }

    // 设置新的定时器
    debounceTimer = setTimeout(async () => {
    try {
    const result = await AIService.complete(context);

    // 显示建议
    // ...
    } catch (error) {
    console.error("AI completion failed:", error);
    }
    }, config.debounceWait || 1500);

  2. 请求取消

    let currentAbortController: AbortController | null = null;

    // 取消旧请求
    if (currentAbortController) {
    currentAbortController.abort();
    }

    // 创建新的 AbortController
    currentAbortController = new AbortController();

    const response = await fetch(url, {
    signal: currentAbortController.signal,
    // ...
    });

优化效果:

  • 减少不必要的 API 调用
  • 节省成本
  • 提升响应速度
  • 避免过时的建议

3.3.3 配置管理与持久化

问题:用户配置需要在应用重启后保持,且需要响应式更新。

解决方案 :使用 VueUse 的 useStorage

复制代码
import { useStorage } from "@vueuse/core";

export function useAIConfig() {
const config = useStorage<AIConfig>(
"milkup-ai-config",
defaultAIConfig,
localStorage,
{ mergeDefaults: true }
);

return { config };
}

优势:

  • 自动同步到 localStorage
  • 响应式更新,配置变化立即生效
  • 支持默认值合并
  • 类型安全

3.3.4 Ollama 模型列表动态获取

问题:Ollama 支持多种本地模型,需要动态获取可用模型列表。

解决方案

复制代码
async function fetchOllamaModels() {
loadingModels.value = true;
try {
const models = await AIService.getModels(config.value);
ollamaModels.value = models;
} catch (e) {
toast.show("获取模型列表失败", "error");
} finally {
loadingModels.value = false;
}
}

// AIService.getModels 实现
static async getModels(config: AIConfig): Promise<string[]> {
if (config.provider === "ollama") {
const res = await this.request(`${config.baseUrl}/api/tags`, {
method: "GET"
});
return res.models?.map((m: any) => m.name) || [];
}
return [];
}

用户体验:

  • 自动检测本地可用模型
  • 下拉选择,无需手动输入
  • 实时刷新

四、对比分析

4.1 即时渲染模式对比

特性 milkup (feat-ir) Typora Notion VS Code + Markdown Preview
开源 ✅ 是 ❌ 否 ❌ 否 ✅ 是
即时渲染 ✅ 是 ✅ 是 ✅ 是 ❌ 否(分栏预览)
源码可见 ✅ 光标聚焦时显示 ✅ 光标聚焦时显示 ❌ 完全隐藏 ✅ 始终显示
源码可编辑 ✅ 链接、图片可直接编辑 ⚠️ 部分支持 ❌ 否 ✅ 是
技术栈 ProseMirror + Milkdown 自研 自研 CodeMirror
扩展性 ✅ 插件化架构 ❌ 不支持插件 ⚠️ 有限的 API ✅ VS Code 插件生态
性能 ✅ 优秀 ✅ 优秀 ⚠️ 大文档较慢 ✅ 优秀
跨平台 ✅ Windows/Mac/Linux ✅ Windows/Mac/Linux ✅ Web/桌面/移动 ✅ Windows/Mac/Linux

milkup 的优势:

  • 开源免费:完全开源,可自由定制
  • 现代化技术栈:基于 Vue 3 + TypeScript + ProseMirror
  • 可扩展性强:插件化架构,易于添加新功能
  • 源码编辑能力:链接和图片可直接编辑,无需切换模式

Typora 的优势:

  • 成熟稳定:经过多年打磨,功能完善
  • 用户体验:细节打磨到位,交互流畅

Notion 的优势:

  • 协作能力:多人实时协作
  • 数据库功能:不仅是编辑器,还是知识管理工具

4.2 AI 续写功能对比

特性 milkup (feat-ai) Cursor Notion AI GitHub Copilot
支持场景 Markdown 文档 代码编辑 文档编辑 代码编辑
多提供商 ✅ OpenAI/Claude/Gemini/Ollama ❌ 仅 OpenAI ❌ 自有模型 ❌ 仅 GitHub 模型
本地模型 ✅ 支持 Ollama ❌ 否 ❌ 否 ❌ 否
结构化理解 ✅ 理解标题层级 ✅ 理解代码结构 ⚠️ 有限 ✅ 理解代码上下文
触发方式 自动防抖触发 自动触发 手动触发 自动触发
接受方式 Tab 键 Tab 键 点击按钮 Tab 键
开源 ✅ 是 ❌ 否 ❌ 否 ❌ 否
隐私保护 ✅ 支持本地模型 ❌ 数据上传云端 ❌ 数据上传云端 ❌ 数据上传云端

milkup 的优势:

  • 多提供商支持:可自由选择 AI 服务
  • 本地优先:支持 Ollama,保护隐私
  • 开源透明:代码公开,可审计
  • 针对 Markdown:专门优化文档写作场景

Cursor/Copilot 的优势:

  • 代码专精:针对代码编辑优化
  • 上下文更丰富:可以理解整个项目
  • 成熟度高:经过大量用户验证

Notion AI 的优势:

  • 多功能:不仅续写,还支持总结、翻译、改写等
  • 集成度高:与 Notion 生态深度集成

五、总结与展望

5.1 技术总结

通过 feat-ir 和 feat-ai 两个分支的开发,milkup 在 Markdown 编辑器领域实现了重要突破:

即时渲染模式(feat-ir):

  • 基于 ProseMirror Decoration 系统实现
  • 智能显示源码,光标聚焦时可见
  • 支持链接和图片的即时编辑
  • 性能优化,大文档流畅运行

AI 续写功能(feat-ai):

  • 支持 OpenAI、Claude、Gemini、Ollama 等多个提供商
  • 结构化理解文档,提取标题层级和上下文
  • 优雅的 UI 集成,半透明建议不干扰阅读
  • 防抖和取消机制,优化性能和成本

技术亮点:

  1. 插件化架构:易于扩展和维护
  2. 现代化技术栈:Vue 3 + TypeScript + ProseMirror
  3. 用户体验优先:流畅的交互,优雅的视觉设计
  4. 开源透明:代码公开,社区驱动

5.2 未来展望

短期计划:

  1. 功能完善
  • 支持更多 Markdown 元素的即时渲染(表格、公式)
  • AI 续写支持更多场景(代码块、列表)
  • 添加 AI 改写、总结等功能
  1. 性能优化
  • 大文档性能优化
  • AI 请求缓存机制
  • 增量渲染
  1. 用户体验
  • 更丰富的快捷键
  • 自定义主题
  • 更多配置选项

长期愿景:

  1. 协作能力
  • 多人实时协作
  • 版本控制集成
  • 评论和批注
  1. 知识管理
  • 双向链接
  • 标签系统
  • 全文搜索
  1. AI 深度集成
  • 智能大纲生成
  • 自动排版优化
  • 多语言翻译
  • 语法检查和改进建议
  1. 生态建设
  • 插件市场
  • 主题商店
  • 社区贡献

5.3 致谢

感谢所有为 milkup 项目做出贡献的开发者和用户。特别感谢:

  • Milkdown 团队:提供了优秀的编辑器框架
  • ProseMirror 社区:强大的编辑器内核
  • Vue.js 团队:现代化的前端框架
  • Anthropic:Claude Code 的开发支持

5.4 参考资源

项目地址:

结语

milkup 的开发是一次有趣的技术探索之旅。我们不仅实现了类似 Typora 的即时渲染模式,还在 AI 集成方面走在了前列。

作为一个开源项目,milkup 的成长离不开社区的支持。我们欢迎任何形式的贡献:代码、文档、建议、bug 报告。让我们一起打造一个更好的 Markdown 编辑器!

如果你对 milkup 感兴趣,欢迎:

  • ⭐ Star 项目
  • 🐛 提交 Issue
  • 🔧 贡献代码
  • 💬 加入讨论

让我们一起推动 Markdown 编辑器的发展!


作者: 德莱厄斯 & Claude Code 日期: 2026 年 2 月 版本: v1.0