波仔椿头像
关注
前端AI编辑器实现:一键润色与智能写作封面图

前端AI编辑器实现:一键润色与智能写作

博主介绍

👨‍💻 了解博主:波仔椿
📖 人生箴言:组件拆得够小,页面才装得下变化。
🧰 我的专栏:Vue3-实战派


文章内容

最近在做一个内容创作平台,产品同学提了个需求:编辑器里得能一键「润色」、能「改写」、能「扩写缩写」,还得能根据标题直接「撰写」一整段。说白了就是把 AI 塞进编辑器里。我翻了一圈方案,Markdown 编辑器用 md-editor-v3,富文本编辑器用 wangeditor v5,AI 流式输出用原生 fetch + ReadableStream 搞定。今天把整套实现拆给你看,从选型到落地,一条龙。

在这里插入图片描述

一、先搞清楚:Markdown 编辑器和富文本编辑器到底差在哪

动手之前先理清概念,不然选型容易跑偏。

Markdown 编辑器:用户写的是 Markdown 源码(比如 ## 标题**加粗**),编辑器负责实时渲染预览。输出物是 .md 文本,轻量、可 diff、适合技术文档和博客。

富文本编辑器:用户直接在「所见即所得」的页面上操作(像 Word 一样选中文字点加粗),输出物是 HTML。适合后台管理、CMS、新闻发布这类场景。

维度Markdown 编辑器(md-editor-v3)富文本编辑器(wangeditor v5)
输入方式写 Markdown 源码所见即所得,直接操作
输出格式Markdown 文本(.mdHTML
实时预览左右分栏实时渲染编辑即预览,无需分栏
版本对比纯文本可 diff,适合 GitHTML diff 噪音大
适合场景技术文档、博客、知识库CMS、后台管理、新闻发布
学习成本需懂 Markdown 语法零门槛,会用 Word 就会
AI 接入替换整段 Markdown 即可需操作选区、插入 HTML

说白了:技术内容用 Markdown 编辑器,业务内容用富文本编辑器,两者各管一摊,别硬凑。

二、用 md-editor-v3 搭一个 Markdown 编辑器

1. 安装与引入

md-editor-v3 是一款专为 Vue3 打造的 Markdown 编辑器,用 JSX + TypeScript 开发,内置 Prettier 美化、Mermaid 图表、KaTeX 公式、图片上传等能力,还支持暗黑主题。当前稳定版 v6.x,要求 Node 16+(建议 18+)。

# 需要 Node 16+,建议 18+
npm i md-editor-v3

入口处引入一次全局样式:

// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import 'md-editor-v3/lib/style.css'

createApp(App).mount('#app')

2. 基础用法:一个能跑的 Markdown 编辑器

先画张组件关系图,一眼看明白整个编辑器的数据流:

App.vue

MdEditor 编辑区

MdPreview 预览区

v-model 文本

editorRef 实例

getSelectedText 获取选中文本

insert 插入内容

下面这个组件拿来就能跑,v-model 双向绑定内容,editorRef 拿编辑器实例:

<!-- src/components/MarkdownEditor.vue -->
<script setup>
import { ref, shallowRef } from 'vue'
import { MdEditor } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'

// 编辑器内容,v-model 双向绑定
const text = ref('# Hello md-editor-v3\n\n写点啥吧……')

// 编辑器实例,用 ref 拿到暴露的方法
const editorRef = ref()

// 工具栏:按需显示,省得界面太挤
const toolbars = [
  'bold', 'underline', 'italic', 'strikeThrough',
  '-', 'title', 'sub', 'sup', 'quote', 'unorderedList', 'orderedList', 'task',
  '-', 'code', 'codeRow', 'link', 'image', 'table', 'mermaid', 'katex',
  '=', 'preview', 'previewOnly', 'fullscreen'
]
</script>

<template>
  <MdEditor
    ref="editorRef"
    v-model="text"
    :toolbars="toolbars"
    editorId="md-editor-1"
    style="height: 500px"
  />
</template>

几个关键点说明:

  • v-model 绑定的是 Markdown 源码字符串,改 text.value 就能改编辑器内容
  • toolbars 数组里的 '-' 是分组分隔符,'=' 后面的按钮会靠右显示
  • editorId 给编辑器一个唯一标识,多个编辑器同页必须不同
  • editorRef 用来调暴露的方法(下一节接 AI 会用)

3. editorRef 暴露的几个关键方法

md-editor-v3v2.5.0 起在实例上暴露了一批方法,接 AI 时会用到这几个:

// 获取当前选中的文本(v4.11.0+ 支持)
const selected = editorRef.value?.getSelectedText()

// 往编辑器里插内容,回调参数是当前选中文本
editorRef.value?.insert((selectedText) => {
  return {
    targetValue: `**${selectedText}**`,  // 插入的内容
    select: true,                        // 插入后是否选中,默认 true
    deviationStart: 0,                   // 选中区域的起始偏移
    deviationEnd: 0                      // 选中区域的结束偏移
  }
})

// 重新渲染预览
editorRef.value?.rerender()

// 手动触发保存(ctrl+s 也会触发)
editorRef.value?.triggerSave()

你发现没有insert 的回调会把你当前选中的文本传进来,这就给「选中一段文字→AI 润色→插回去」留好了口子,设计得很巧妙。

三、用 wangeditor v5 搭一个富文本编辑器

1. 安装与引入

wangeditor v5 基于 slate.js 开发,Vue3 要装 @wangeditor/editor-for-vue@next(注意带 @next,不带的是 Vue2 版)。

# @next 是 Vue3 版,不带是 Vue2 版,别装错
npm i @wangeditor/editor @wangeditor/editor-for-vue@next

2. 基础用法:一个能跑的富文本编辑器

wangeditor 的结构是「工具栏 + 编辑区」两部分,比 md-editor-v3 多一个 Toolbar 组件:

RichEditor.vue

Toolbar 工具栏

Editor 编辑区

editorRef 实例

getSelectionText 获取选中文本

dangerouslyInsertHtml 插入 HTML

deleteFragment 删除选中

restoreSelection 恢复选区

下面这个组件拿来就能跑,注意 editorRef 必须用 shallowRef

<!-- src/components/RichEditor.vue -->
<script setup>
import '@wangeditor/editor/dist/css/style.css'
import { onBeforeUnmount, ref, shallowRef } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'

// 编辑器实例,必须用 shallowRef,用 ref 会报错
const editorRef = shallowRef()

// 编辑器内容 HTML
const valueHtml = ref('<p>hello wangeditor</p>')

const toolbarConfig = {}
const editorConfig = { placeholder: '请输入内容...' }

// 拿到 editor 实例
const handleCreated = (editor) => {
  editorRef.value = editor
}

// 组件销毁时必须销毁编辑器,否则内存泄漏
onBeforeUnmount(() => {
  const editor = editorRef.value
  if (editor == null) return
  editor.destroy()
})
</script>

<template>
  <div style="border: 1px solid #ccc">
    <Toolbar
      style="border-bottom: 1px solid #ccc"
      :editor="editorRef"
      :defaultConfig="toolbarConfig"
      mode="default"
    />
    <Editor
      style="height: 500px; overflow-y: hidden"
      v-model="valueHtml"
      :defaultConfig="editorConfig"
      mode="default"
      @onCreated="handleCreated"
    />
  </div>
</template>

这里有几个坑点必须提前说:

坑点原因解法
editorRefref 报错editor 实例是复杂对象,Vue 深度代理会冲突必须用 shallowRef
组件销毁后内存泄漏编辑器绑了全局事件,不手动销毁不会释放onBeforeUnmount 里调 editor.destroy()
onXxx 生命周期不生效wangeditor 的生命周期必须用 Vue 事件传,不能写进 editorConfig@onChange@onFocus 等模板事件
异步设置内容不显示编辑器渲染前赋值会被覆盖@onCreated 之后赋值

有意思的地方就在这儿:wangeditor 把 onXxx 生命周期强制走 Vue 事件,是故意不让塞进 editorConfig——这样事件和配置解耦,组件卸毁时 Vue 能自动帮你解绑,设计上比一股脑塞配置要干净。

3. editorRef 暴露的关键方法(接 AI 会用)

wangeditor 的实例方法很多,接 AI 文本处理主要用这几个:

const editor = editorRef.value

// 获取选中的纯文本
const selectedText = editor.getSelectionText()

// 删除当前选中的内容(润色/改写后要先删旧内容再插新的)
editor.deleteFragment()

// 插入 HTML,注意方法名带 dangerously,非 editor.getHtml() 格式不能保证语义正确
editor.dangerouslyInsertHtml('<p>AI 润色后的内容</p>')

// 在选区插入纯文本
editor.insertText('一段文字')

// 恢复上一次的选区(编辑器失焦后选区会丢,得先存再恢复)
editor.restoreSelection()

// 聚焦编辑器
editor.focus()

接 AI 的核心流程就是:getSelectionText() 拿选中文字 → 调 AI → deleteFragment() 删旧的 → dangerouslyInsertHtml() 插新的。下面把这套路子封装成通用能力。

四、AI 文本能力的实现思路

1. AI 能力分类与 Prompt 设计

产品要的五种 AI 能力,对应五种不同的 Prompt 模板:

能力作用Prompt 模板
润色优化措辞、修正语病,保留原意请润色以下文本,保持原意,使表达更流畅:${text}
改写换一种表达方式重写请改写以下文本,换一种表达方式:${text}
扩写在原文基础上展开补充请扩写以下文本,补充细节使内容更丰富:${text}
缩写精简压缩,保留核心信息请缩写以下文本,保留核心信息,控制在原长一半:${text}
撰写根据标题/要点直接生成请根据以下标题撰写一段内容,约300字:${text}

2. 流式输出:为什么不用一次性返回

AI 生成几百字往往要 5-15 秒,如果走「等全部生成完再返回」的非流式模式,用户对着空白干等,体验很差。流式输出让 AI 像打字机一样逐字吐出来,用户立刻就能看到内容在长,体感延迟从「等 10 秒」变成「立刻有动静」。

先看流式和非流式的数据流差异:

流式

用户请求

服务器边生成边推送

前端边接收边渲染

非流式

用户请求

服务器生成全部

一次性返回 JSON

前端渲染

3. 封装 useAIStream 组合式函数

核心思路:用 fetch 发请求,从 response.body 拿到 ReadableStream,用 getReader() 逐块读,TextDecoder 把二进制解码成字符串,按 SSE 格式(data: {json})解析出增量文本。

这里有个大坑:TCP 分包不保证和 SSE 的逻辑行对齐,一个 JSON 可能被拆成两半。解法是维护一个 buffer,解析失败的行先存起来,等下一块拼接后再解析。

// src/composables/useAIStream.js
import { ref, onUnmounted } from 'vue'

/**
 * AI 文本流式请求组合式函数
 * @param {string} endpoint 后端 AI 接口地址
 */
export function useAIStream(endpoint) {
  // 流式输出的文本,逐字增长
  const result = ref('')
  // 是否正在生成
  const loading = ref(false)
  // 错误信息
  const error = ref('')

  let controller = null

  /**
   * 发起一次 AI 文本处理请求
   * @param {string} text 待处理的文本
   * @param {string} action 处理类型:polish|rewrite|expand|shorten|write
   */
  const generate = async (text, action) => {
    if (!text.trim()) {
      error.value = '文本不能为空'
      return
    }
    // 每次请求前重置状态
    result.value = ''
    error.value = ''
    loading.value = true

    // 用 AbortController 支持中途取消
    controller = new AbortController()

    try {
      const response = await fetch(endpoint, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text, action }),
        signal: controller.signal
      })

      if (!response.ok) {
        throw new Error(`请求失败:${response.status}`)
      }

      // 核心:从 body 拿到可读流
      const reader = response.body.getReader()
      const decoder = new TextDecoder()
      let buffer = ''  // 缓存不完整的 JSON 行

      while (true) {
        const { value, done } = await reader.read()
        if (done) break

        // stream: true 防止多字节字符(中文)被截断
        const chunk = buffer + decoder.decode(value, { stream: true })
        buffer = ''

        // SSE 格式:每条消息以 data: 开头,用换行分隔
        const lines = chunk.split('\n').filter(line => line.startsWith('data: '))

        for (const line of lines) {
          // 去掉 "data: " 前缀
          const raw = line.slice(6).trim()

          // [DONE] 是流结束的哨兵值
          if (raw === '[DONE]') {
            loading.value = false
            return
          }

          try {
            const json = JSON.parse(raw)
            // OpenAI 兼容格式:增量在 choices[0].delta.content
            const delta = json.choices?.[0]?.delta?.content || ''
            if (delta) {
              result.value += delta
            }
          } catch (e) {
            // JSON 不完整(被 TCP 拆断),存进 buffer 等下一轮
            buffer = `data: ${raw}`
          }
        }
      }
    } catch (err) {
      // 用户主动取消会抛 AbortError,不算错误
      if (err.name !== 'AbortError') {
        error.value = err.message
      }
    } finally {
      loading.value = false
    }
  }

  // 取消当前请求
  const cancel = () => {
    if (controller) {
      controller.abort()
      controller = null
      loading.value = false
    }
  }

  // 组件卸载时取消未完成的请求
  onUnmounted(cancel)

  return { result, loading, error, generate, cancel }
}

这段代码有几个关键设计:

  1. buffer 缓存:解析失败的 data: 行不丢,留着等下一块拼上再解析,解决 JSON 被拆断的问题
  2. { stream: true }TextDecoder.decode 加这个参数,防止中文字符(UTF-8 三字节)被从中间截断
  3. AbortController:用户不想等了能立刻取消,组件卸载也会自动取消,不会留垃圾请求
  4. [DONE] 哨兵:OpenAI 兼容的 SSE 协议用这个标记流结束

4. 流式输出的时序

把整个流式过程画成时序图,谁调谁一目了然:

后端AI useAIStream 组件 用户 后端AI useAIStream 组件 用户 选中文字,点「润色」 generate(text, 'polish') POST { text, action } stream data: {"delta":{"content":"润"}} result.value += "润" 文字逐字显示 data: {"delta":{"content":"色"}} result.value += "色" 文字继续长 data: [DONE] loading = false 把结果插回编辑器

一句话总结:流式的本质不是「更快」,而是「让用户立刻看到有动静」,用即时反馈对抗等待焦虑。

五、把编辑器和 AI 接到一起:完整实战

1. 整体架构

两种编辑器接 AI 的套路略有不同,画张图先理清:

Markdown

富文本

Markdown

富文本

用户选中文字

编辑器类型

editorRef.getSelectedText

editor.getSelectionText

useAIStream.generate

后端 AI 流式返回

流式结果逐字显示在预览区

用户确认应用

editorRef.insert 替换选区

editor.deleteFragment + dangerouslyInsertHtml

核心差异在最后一步:Markdown 编辑器用 insert 替换选区,富文本编辑器用 deleteFragment 删旧的再 dangerouslyInsertHtml 插新的。

2. Markdown 编辑器 + AI 完整组件

把第二节的基础组件升级一下,加一个 AI 工具栏,支持润色、改写、扩写、缩写:

<!-- src/components/MdEditorWithAI.vue -->
<script setup>
import { ref } from 'vue'
import { MdEditor, NormalToolbar } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import { useAIStream } from '../composables/useAIStream'

const text = ref('# 标题\n\n这是一段需要润色的文字,写得不太通顺,你帮我改改。')
const editorRef = ref()

// AI 流式请求,endpoint 换成你的后端地址
const { result: aiResult, loading: aiLoading, generate, cancel } = useAIStream('/api/ai/text')

// AI 动作配置
const actions = [
  { key: 'polish', label: '润色' },
  { key: 'rewrite', label: '改写' },
  { key: 'expand', label: '扩写' },
  { key: 'shorten', label: '缩写' }
]

// 触发 AI 处理
const handleAI = async (action) => {
  // 先拿编辑器里选中的文字,没选中就用整篇
  const selected = editorRef.value?.getSelectedText() || text.value
  if (!selected.trim()) return
  await generate(selected, action)
  // 流式结束后,把结果插回编辑器(替换选区)
  editorRef.value?.insert(() => ({
    targetValue: aiResult.value,
    select: true
  }))
}
</script>

<template>
  <div>
    <!-- AI 工具栏:通过 defToolbars 插槽注入自定义按钮 -->
    <MdEditor
      ref="editorRef"
      v-model="text"
      editorId="md-ai-1"
      :defToolbars="actions"
      style="height: 500px"
    >
      <template #defToolbars>
        <NormalToolbar
          v-for="action in actions"
          :key="action.key"
          :title="`AI${action.label}`"
        >
          <button
            class="ai-btn"
            :disabled="aiLoading"
            @click="handleAI(action.key)"
          >
            {{ aiLoading ? '生成中…' : action.label }}
          </button>
        </NormalToolbar>
      </template>
    </MdEditor>

    <!-- 流式结果实时预览 -->
    <div v-if="aiLoading || aiResult" class="ai-preview">
      <div class="ai-preview-header">
        <span>AI 生成结果</span>
        <button v-if="aiLoading" class="ai-cancel" @click="cancel">停止</button>
      </div>
      <div class="ai-preview-body">{{ aiResult }}</div>
    </div>
  </div>
</template>

<style scoped>
.ai-btn {
  padding: 4px 10px;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  background: #f0f9ff;
  cursor: pointer;
  font-size: 12px;
}
.ai-btn:disabled {
  cursor: not-allowed;
  opacity: 0.6;
}
.ai-preview {
  margin-top: 12px;
  border: 1px solid #e4e7ed;
  border-radius: 4px;
  padding: 12px;
  background: #fafafa;
}
.ai-preview-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
  font-size: 14px;
  color: #606266;
}
.ai-cancel {
  border: none;
  background: #f56c6c;
  color: #fff;
  padding: 2px 10px;
  border-radius: 4px;
  cursor: pointer;
}
.ai-preview-body {
  white-space: pre-wrap;
  line-height: 1.6;
  color: #303133;
}
</style>

这里用 NormalToolbar 把自定义的 AI 按钮塞进编辑器工具栏,点击后通过 getSelectedText() 拿选中文字,调 AI 流式生成,结束后用 insert 插回。

3. 富文本编辑器 + AI 完整组件

富文本这边套路类似,但最后一步用 deleteFragment + dangerouslyInsertHtml,而且要先 restoreSelection 恢复选区(因为点按钮时编辑器失焦了):

<!-- src/components/RichEditorWithAI.vue -->
<script setup>
import '@wangeditor/editor/dist/css/style.css'
import { onBeforeUnmount, ref, shallowRef } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { useAIStream } from '../composables/useAIStream'

const editorRef = shallowRef()
const valueHtml = ref('<p>这是一段需要润色的文字,写得不太通顺,你帮我改改。</p>')

const handleCreated = (editor) => {
  editorRef.value = editor
}

onBeforeUnmount(() => {
  editorRef.value?.destroy()
})

const { result: aiResult, loading: aiLoading, generate, cancel } = useAIStream('/api/ai/text')

const actions = [
  { key: 'polish', label: '润色' },
  { key: 'rewrite', label: '改写' },
  { key: 'expand', label: '扩写' },
  { key: 'shorten', label: '缩写' }
]

const handleAI = async (action) => {
  const editor = editorRef.value
  if (!editor) return

  // 拿选中的纯文本,没选中就用整篇纯文本
  let selected = editor.getSelectionText()
  if (!selected.trim()) {
    selected = editor.getText()
  }
  if (!selected.trim()) return

  await generate(selected, action)

  // 流式结束后,把结果插回编辑器
  editor.restoreSelection()   // 先恢复选区(点按钮时失焦了)
  editor.deleteFragment()     // 删除原来选中的内容
  editor.dangerouslyInsertHtml(`<p>${aiResult.value}</p>`)  // 插入 AI 生成的内容
}
</script>

<template>
  <div>
    <!-- AI 操作栏,放在编辑器上方 -->
    <div class="ai-toolbar">
      <button
        v-for="action in actions"
        :key="action.key"
        class="ai-btn"
        :disabled="aiLoading"
        @click="handleAI(action.key)"
      >
        {{ aiLoading ? '生成中…' : action.label }}
      </button>
      <button v-if="aiLoading" class="ai-cancel" @click="cancel">停止生成</button>
    </div>

    <div style="border: 1px solid #ccc">
      <Toolbar
        style="border-bottom: 1px solid #ccc"
        :editor="editorRef"
        mode="default"
      />
      <Editor
        style="height: 500px; overflow-y: hidden"
        v-model="valueHtml"
        mode="default"
        @onCreated="handleCreated"
      />
    </div>

    <div v-if="aiLoading || aiResult" class="ai-preview">
      <div class="ai-preview-header">
        <span>AI 生成结果</span>
      </div>
      <div class="ai-preview-body">{{ aiResult }}</div>
    </div>
  </div>
</template>

<style scoped>
.ai-toolbar {
  display: flex;
  gap: 8px;
  margin-bottom: 8px;
}
.ai-btn {
  padding: 6px 16px;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  background: #f0f9ff;
  cursor: pointer;
  font-size: 14px;
}
.ai-btn:disabled {
  cursor: not-allowed;
  opacity: 0.6;
}
.ai-cancel {
  border: none;
  background: #f56c6c;
  color: #fff;
  padding: 6px 16px;
  border-radius: 4px;
  cursor: pointer;
}
.ai-preview {
  margin-top: 12px;
  border: 1px solid #e4e7ed;
  border-radius: 4px;
  padding: 12px;
  background: #fafafa;
}
.ai-preview-header {
  margin-bottom: 8px;
  font-size: 14px;
  color: #606266;
}
.ai-preview-body {
  white-space: pre-wrap;
  line-height: 1.6;
  color: #303133;
}
</style>

关键点:富文本编辑器点按钮会失焦,选区丢了,必须先 restoreSelection() 恢复选区,再 deleteFragment() 删旧内容,最后 dangerouslyInsertHtml() 插新内容,三步缺一不可。

六、踩坑与优化

实际跑下来有几个坑得提一下:

1. 高频追加导致渲染卡顿

result.value += delta 每来一个 token 就触发一次响应式更新,AI 输出快时一秒能触发几十次,DOM 更新跟不上会卡。用 requestAnimationFrame 限流,合并到每帧只渲染一次:

// src/composables/useAIStream.js(节选优化版)
let pendingDelta = ''
let scheduled = false

const flushDelta = () => {
  result.value += pendingDelta
  pendingDelta = ''
  scheduled = false
}

const appendDelta = (delta) => {
  pendingDelta += delta
  if (!scheduled) {
    scheduled = true
    requestAnimationFrame(flushDelta)
  }
}

// 流式循环里把 result.value += delta 换成 appendDelta(delta)

2. 编辑器选区丢失

富文本编辑器点工具栏按钮会失焦,选区丢了。wangeditorrestoreSelection() 能恢复上一次选区,但前提是你没在中间做过其他选区操作。md-editor-v3insert 是自己处理选区的,不用额外操心。

3. 取消请求的清理

AbortController 取消后,fetch 会抛 AbortError,得在 catch 里判断 err.name !== 'AbortError' 再标记错误,不然用户主动取消也会显示报错。

4. 后端接口格式

前端这套流式解析是按 OpenAI 兼容的 SSE 格式写的(data: {json}\n\n[DONE] 结束)。如果你的后端用的是别的协议(比如纯文本流、WebSocket),解析逻辑要相应调整,但 ReadableStream + TextDecoder + buffer 这套骨架是不变的。


精彩推荐

🔎AI时代:UI设计师效率翻倍的秘密

🔎巧用 AI 赋能新闻工作:提质增效,筑牢新闻真实底线

🔎AI医疗革命:医生会被取代吗?

🔎短视频爆款秘籍:AI助力3倍速涨粉

🔎2026年,如何借助AI工具打造IP爆款账号?


本篇博客文章唯一版权归属©波仔椿

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/weixin_48337566/article/details/163664979

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--