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 编辑器的发展历程中,编辑模式经历了几个阶段:
- 分栏预览模式(如早期的 MarkdownPad):左侧源码,右侧预览,割裂感强
- 纯所见即所得模式(如 Notion):完全隐藏语法,失去了 Markdown 的简洁性
- 即时渲染模式(如 Typora):平衡了语法可见性和渲染效果
Typora 的成功证明了即时渲染模式的优越性:
- 写作流畅性:不需要在源码和预览之间切换视线
- 语法可控性:需要时可以看到和编辑原始语法
- 视觉舒适性:大部分时间看到的是渲染后的效果
然而,Typora 是闭源软件,且已经停止免费更新。市面上缺少一个开源、现代化、可扩展的即时渲染编辑器。这就是 feat-ir 分支的诞生背景。
1.3 为什么需要 AI 续写功能?
随着 AI 技术的发展,智能写作辅助已经成为现代编辑器的标配:
- Cursor、GitHub 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) :
- 标题:显示对应数量的 # 符号(如 #表示一级标题)
- 图片:显示  完整语法
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 处理策略
对于行内标记(如加粗、斜体、链接),我们需要在文本前后添加语法符号:
实现思路:
- 遍历光标位置的 Marks:获取当前光标所在位置的所有标记
- 计算标记范围:找到每个标记的起始和结束位置
- 创建装饰器:在起始位置前和结束位置后插入语法符号
以链接为例:- // 处理链接标记
- 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;
- // 创建 ;
- 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 装饰器性能优化
问题:每次光标移动都重新计算所有装饰器,可能导致性能问题。
解决方案:
- 增量更新:只在光标位置变化时更新装饰器
- 缓存机制:缓存上一次的装饰器集合,避免重复计算
- 范围限制:只处理光标附近的元素,不遍历整个文档
- 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 与其他插件的兼容性
问题:装饰器可能与其他插件(如拼写检查、语法高亮)冲突。
解决方案:
- 装饰器优先级:使用 ProseMirror 的 spec.key 设置优先级
- 避免重叠:检测装饰器是否重叠,避免覆盖
- 事件隔离:使用 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 续写不是简单地续接文本,而是理解文档的结构:
提取的上下文信息:
- 文件名:了解文档主题
- 标题层级:理解文档结构和当前章节
- 前文内容:分析写作风格和上下文
- 光标位置:确定续写的起点
示例:
假设你正在写一篇技术博客:- # 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);
- }
- }
复制代码 各提供商的实现差异:
- 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"]
- }
- }
- }
- }
- };
复制代码
- 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" }
- }
- };
复制代码
- Google Gemini:使用 responseMimeType 和 responseSchema
- 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"]
- }
- }
- }
- };
复制代码
- 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 实现了智能的上下文提取策略。
提取的信息:
- const fileTitle = (window as any).__currentFilePath
- ? (window as any).__currentFilePath.split(/[\/]/).pop()
- : "未命名文档";
复制代码- const start = Math.max(0, to - 200);
- const previousContent = doc.textBetween(start, to, "\n");
复制代码- 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;
- });
复制代码 构建 Prompt:- 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;
- }
- }
复制代码 System Prompt:- private static buildPrompt(context: APIContext): string {
- return `上下文:
- 文章标题:${context.fileTitle || "未知"}
- 大标题:${context.sectionTitle || "未知"}
- 本小节标题:${context.subSectionTitle || "未知"}
- 前面内容(请紧密衔接):${context.previousContent}`;
- }
复制代码 设计理念:
- 结构化理解:不仅提供文本,还提供文档结构
- 精确定位:明确当前所在的章节位置
- 长度控制:限制 3-35 个汉字,避免过度生成
- 格式约束:强制 JSON 输出,便于解析
3.2.4 UI 显示机制
建议的显示使用 ProseMirror 的 Decoration 系统,在光标位置插入半透明的建议文本。
创建建议 Widget:- const SYSTEM_PROMPT = `你是一个技术文档续写助手。
- 严格只输出以下 JSON,**不要有任何前缀、后缀、markdown、换行、解释**:
- {"continuation": "接下来只写3–35个汉字的自然衔接内容"}
- `;
复制代码 样式设计:- // 创建建议元素
- 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;
- }
- }
复制代码- 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;
- }
- }
复制代码 用户体验细节:
- 半透明显示:不干扰正常阅读
- 斜体样式:与正文区分
- 不可交互:不影响鼠标点击和文本选择
- 即时清除:用户继续输入时自动消失
- 快捷接受:Tab 键一键接受
3.3 技术挑战与解决方案
3.3.1 结构化输出的挑战
问题:不同的 AI 模型对输出格式的控制能力不同,有些模型可能输出额外的文本、markdown 代码块或解释性内容。
解决方案:
- OpenAI:使用 response_format 的 JSON Schema
- Claude:使用 Tool Use 机制
- Gemini:使用 responseMimeType 和 responseSchema
- Ollama:使用 format 参数
- apply(tr, value) {
- // 文档内容变化时清除建议
- if (tr.docChanged) {
- return {
- decoration: DecorationSet.empty,
- suggestion: null,
- loading: false
- };
- }
- return value;
- }
复制代码 鲁棒性保证:
- 清理 markdown 代码块标记
- 正则表达式兜底
- 短文本直接使用
- 多层容错机制
3.3.2 防抖与取消机制
问题:用户输入时频繁触发 AI 请求,浪费资源且影响体验。
解决方案:
- 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");
- }
复制代码- 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);
复制代码 优化效果:
- 减少不必要的 API 调用
- 节省成本
- 提升响应速度
- 避免过时的建议
3.3.3 配置管理与持久化
问题:用户配置需要在应用重启后保持,且需要响应式更新。
解决方案:使用 VueUse 的 useStorage- let currentAbortController: AbortController | null = null;
- // 取消旧请求
- if (currentAbortController) {
- currentAbortController.abort();
- }
- // 创建新的 AbortController
- currentAbortController = new AbortController();
- const response = await fetch(url, {
- signal: currentAbortController.signal,
- // ...
- });
复制代码 优势:
- 自动同步到 localStorage
- 响应式更新,配置变化立即生效
- 支持默认值合并
- 类型安全
3.3.4 Ollama 模型列表动态获取
问题:Ollama 支持多种本地模型,需要动态获取可用模型列表。
解决方案:- import { useStorage } from "@vueuse/core";
- export function useAIConfig() {
- const config = useStorage(
- "milkup-ai-config",
- defaultAIConfig,
- localStorage,
- { mergeDefaults: true }
- );
- return { config };
- }
复制代码 用户体验:
- 自动检测本地可用模型
- 下拉选择,无需手动输入
- 实时刷新
四、对比分析
4.1 即时渲染模式对比
特性milkup (feat-ir)TyporaNotionVS Code + Markdown Preview开源✅ 是❌ 否❌ 否✅ 是即时渲染✅ 是✅ 是✅ 是❌ 否(分栏预览)源码可见✅ 光标聚焦时显示✅ 光标聚焦时显示❌ 完全隐藏✅ 始终显示源码可编辑✅ 链接、图片可直接编辑⚠️ 部分支持❌ 否✅ 是技术栈ProseMirror + Milkdown自研自研CodeMirror扩展性✅ 插件化架构❌ 不支持插件⚠️ 有限的 API✅ VS Code 插件生态性能✅ 优秀✅ 优秀⚠️ 大文档较慢✅ 优秀跨平台✅ Windows/Mac/Linux✅ Windows/Mac/Linux✅ Web/桌面/移动✅ Windows/Mac/Linuxmilkup 的优势:
- 开源免费:完全开源,可自由定制
- 现代化技术栈:基于 Vue 3 + TypeScript + ProseMirror
- 可扩展性强:插件化架构,易于添加新功能
- 源码编辑能力:链接和图片可直接编辑,无需切换模式
Typora 的优势:
- 成熟稳定:经过多年打磨,功能完善
- 用户体验:细节打磨到位,交互流畅
Notion 的优势:
- 协作能力:多人实时协作
- 数据库功能:不仅是编辑器,还是知识管理工具
4.2 AI 续写功能对比
特性milkup (feat-ai)CursorNotion AIGitHub 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 集成,半透明建议不干扰阅读
- 防抖和取消机制,优化性能和成本
技术亮点:
- 插件化架构:易于扩展和维护
- 现代化技术栈:Vue 3 + TypeScript + ProseMirror
- 用户体验优先:流畅的交互,优雅的视觉设计
- 开源透明:代码公开,社区驱动
5.2 未来展望
短期计划:
- 支持更多 Markdown 元素的即时渲染(表格、公式)
- AI 续写支持更多场景(代码块、列表)
- 添加 AI 改写、总结等功能
长期愿景:
- 智能大纲生成
- 自动排版优化
- 多语言翻译
- 语法检查和改进建议
5.3 致谢
感谢所有为 milkup 项目做出贡献的开发者和用户。特别感谢:
- Milkdown 团队:提供了优秀的编辑器框架
- ProseMirror 社区:强大的编辑器内核
- Vue.js 团队:现代化的前端框架
- Anthropic:Claude Code 的开发支持
5.4 参考资源
项目地址:
结语
milkup 的开发是一次有趣的技术探索之旅。我们不仅实现了类似 Typora 的即时渲染模式,还在 AI 集成方面走在了前列。
作为一个开源项目,milkup 的成长离不开社区的支持。我们欢迎任何形式的贡献:代码、文档、建议、bug 报告。让我们一起打造一个更好的 Markdown 编辑器!
如果你对 milkup 感兴趣,欢迎:
<ul>⭐ Star 项目
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |