• 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 {
    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"]
    }
    }
    }
    }
    };
    
    1. 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" }
    }
    };
    
    1. 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"]
    }
    }
    }
    };
    
    1. 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()
    : "未命名文档";
    
    1. 前文内容:获取光标前最近 200 个字符
    const start = Math.max(0, to - 200);
    const previousContent = doc.textBetween(start, to, "\n");
    
    1. 标题层级:遍历文档提取所有标题
    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;
    });
    
    1. 当前章节:确定当前所在的章节和子章节
    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;
    }
    }
    
    1. 自动清除机制
    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);
    
    1. 请求取消
    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(
    "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 {
    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

  • 相关阅读:
    关于Android Framework渲染机制,你需要学习哪些?
    uni-app——如何阻止事件冒泡
    jQuery绑定事件bind
    Arduino驱动VEML7700传感器(光照传感器篇)
    分享 2022 年最受欢迎的黑科技工具(二)
    Jmeter性能综合实战 —— 签到及批量签到
    计算机毕业设计ssm家教管理系统dpf3v系统+程序+源码+lw+远程部署
    使用 Docker 自建一款怀旧游戏之 - 超级马里奥
    springboot + easyRules 搭建规则引擎服务
    PCL 使用克拉默法则进行三点定圆(二维)
  • 原文地址:https://www.cnblogs.com/Kay-Larry/p/19575577