博主介绍
👨💻 了解博主:波仔椿 📖 人生箴言:组件拆得够小,页面才装得下变化。 🧰 我的专栏:Vue3-实战派
文章内容
最近在做一个内容创作平台,产品同学提了个需求:编辑器里得能一键「润色」、能「改写」、能「扩写缩写」,还得能根据标题直接「撰写」一整段。说白了就是把 AI 塞进编辑器里。我翻了一圈方案,Markdown 编辑器用 md-editor-v3,富文本编辑器用 wangeditor v5,AI 流式输出用原生 fetch + ReadableStream 搞定。今天把整套实现拆给你看,从选型到落地,一条龙。

一、先搞清楚:Markdown 编辑器和富文本编辑器到底差在哪
动手之前先理清概念,不然选型容易跑偏。
Markdown 编辑器:用户写的是 Markdown 源码(比如 ## 标题、**加粗**),编辑器负责实时渲染预览。输出物是 .md 文本,轻量、可 diff、适合技术文档和博客。
富文本编辑器:用户直接在「所见即所得」的页面上操作(像 Word 一样选中文字点加粗),输出物是 HTML。适合后台管理、CMS、新闻发布这类场景。
| 输入方式 | 写 Markdown 源码 | 所见即所得,直接操作 |
| 输出格式 | Markdown 文本(.md) | HTML |
| 实时预览 | 左右分栏实时渲染 | 编辑即预览,无需分栏 |
| 版本对比 | 纯文本可 diff,适合 Git | HTML 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 编辑器
先画张组件关系图,一眼看明白整个编辑器的数据流:
#mermaid-svg-K9rJuu07At8by80X{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-K9rJuu07At8by80X .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-K9rJuu07At8by80X .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-K9rJuu07At8by80X .error-icon{fill:#552222;}#mermaid-svg-K9rJuu07At8by80X .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-K9rJuu07At8by80X .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-K9rJuu07At8by80X .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-K9rJuu07At8by80X .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-K9rJuu07At8by80X .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-K9rJuu07At8by80X .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-K9rJuu07At8by80X .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-K9rJuu07At8by80X .marker{fill:#333333;stroke:#333333;}#mermaid-svg-K9rJuu07At8by80X .marker.cross{stroke:#333333;}#mermaid-svg-K9rJuu07At8by80X svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-K9rJuu07At8by80X p{margin:0;}#mermaid-svg-K9rJuu07At8by80X .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-K9rJuu07At8by80X .cluster-label text{fill:#333;}#mermaid-svg-K9rJuu07At8by80X .cluster-label span{color:#333;}#mermaid-svg-K9rJuu07At8by80X .cluster-label span p{background-color:transparent;}#mermaid-svg-K9rJuu07At8by80X .label text,#mermaid-svg-K9rJuu07At8by80X span{fill:#333;color:#333;}#mermaid-svg-K9rJuu07At8by80X .node rect,#mermaid-svg-K9rJuu07At8by80X .node circle,#mermaid-svg-K9rJuu07At8by80X .node ellipse,#mermaid-svg-K9rJuu07At8by80X .node polygon,#mermaid-svg-K9rJuu07At8by80X .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-K9rJuu07At8by80X .rough-node .label text,#mermaid-svg-K9rJuu07At8by80X .node .label text,#mermaid-svg-K9rJuu07At8by80X .image-shape .label,#mermaid-svg-K9rJuu07At8by80X .icon-shape .label{text-anchor:middle;}#mermaid-svg-K9rJuu07At8by80X .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-K9rJuu07At8by80X .rough-node .label,#mermaid-svg-K9rJuu07At8by80X .node .label,#mermaid-svg-K9rJuu07At8by80X .image-shape .label,#mermaid-svg-K9rJuu07At8by80X .icon-shape .label{text-align:center;}#mermaid-svg-K9rJuu07At8by80X .node.clickable{cursor:pointer;}#mermaid-svg-K9rJuu07At8by80X .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-K9rJuu07At8by80X .arrowheadPath{fill:#333333;}#mermaid-svg-K9rJuu07At8by80X .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-K9rJuu07At8by80X .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-K9rJuu07At8by80X .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-K9rJuu07At8by80X .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-K9rJuu07At8by80X .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-K9rJuu07At8by80X .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-K9rJuu07At8by80X .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-K9rJuu07At8by80X .cluster text{fill:#333;}#mermaid-svg-K9rJuu07At8by80X .cluster span{color:#333;}#mermaid-svg-K9rJuu07At8by80X div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-K9rJuu07At8by80X .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-K9rJuu07At8by80X rect.text{fill:none;stroke-width:0;}#mermaid-svg-K9rJuu07At8by80X .icon-shape,#mermaid-svg-K9rJuu07At8by80X .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-K9rJuu07At8by80X .icon-shape p,#mermaid-svg-K9rJuu07At8by80X .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-K9rJuu07At8by80X .icon-shape .label rect,#mermaid-svg-K9rJuu07At8by80X .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-K9rJuu07At8by80X .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-K9rJuu07At8by80X .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-K9rJuu07At8by80X :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
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-v3 从 v2.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 组件:
#mermaid-svg-TgiGbLyY0awoEcLt{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-TgiGbLyY0awoEcLt .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-TgiGbLyY0awoEcLt .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-TgiGbLyY0awoEcLt .error-icon{fill:#552222;}#mermaid-svg-TgiGbLyY0awoEcLt .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-TgiGbLyY0awoEcLt .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-TgiGbLyY0awoEcLt .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-TgiGbLyY0awoEcLt .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-TgiGbLyY0awoEcLt .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-TgiGbLyY0awoEcLt .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-TgiGbLyY0awoEcLt .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-TgiGbLyY0awoEcLt .marker{fill:#333333;stroke:#333333;}#mermaid-svg-TgiGbLyY0awoEcLt .marker.cross{stroke:#333333;}#mermaid-svg-TgiGbLyY0awoEcLt svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-TgiGbLyY0awoEcLt p{margin:0;}#mermaid-svg-TgiGbLyY0awoEcLt .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-TgiGbLyY0awoEcLt .cluster-label text{fill:#333;}#mermaid-svg-TgiGbLyY0awoEcLt .cluster-label span{color:#333;}#mermaid-svg-TgiGbLyY0awoEcLt .cluster-label span p{background-color:transparent;}#mermaid-svg-TgiGbLyY0awoEcLt .label text,#mermaid-svg-TgiGbLyY0awoEcLt span{fill:#333;color:#333;}#mermaid-svg-TgiGbLyY0awoEcLt .node rect,#mermaid-svg-TgiGbLyY0awoEcLt .node circle,#mermaid-svg-TgiGbLyY0awoEcLt .node ellipse,#mermaid-svg-TgiGbLyY0awoEcLt .node polygon,#mermaid-svg-TgiGbLyY0awoEcLt .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-TgiGbLyY0awoEcLt .rough-node .label text,#mermaid-svg-TgiGbLyY0awoEcLt .node .label text,#mermaid-svg-TgiGbLyY0awoEcLt .image-shape .label,#mermaid-svg-TgiGbLyY0awoEcLt .icon-shape .label{text-anchor:middle;}#mermaid-svg-TgiGbLyY0awoEcLt .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-TgiGbLyY0awoEcLt .rough-node .label,#mermaid-svg-TgiGbLyY0awoEcLt .node .label,#mermaid-svg-TgiGbLyY0awoEcLt .image-shape .label,#mermaid-svg-TgiGbLyY0awoEcLt .icon-shape .label{text-align:center;}#mermaid-svg-TgiGbLyY0awoEcLt .node.clickable{cursor:pointer;}#mermaid-svg-TgiGbLyY0awoEcLt .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-TgiGbLyY0awoEcLt .arrowheadPath{fill:#333333;}#mermaid-svg-TgiGbLyY0awoEcLt .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-TgiGbLyY0awoEcLt .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-TgiGbLyY0awoEcLt .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-TgiGbLyY0awoEcLt .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-TgiGbLyY0awoEcLt .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-TgiGbLyY0awoEcLt .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-TgiGbLyY0awoEcLt .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-TgiGbLyY0awoEcLt .cluster text{fill:#333;}#mermaid-svg-TgiGbLyY0awoEcLt .cluster span{color:#333;}#mermaid-svg-TgiGbLyY0awoEcLt div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-TgiGbLyY0awoEcLt .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-TgiGbLyY0awoEcLt rect.text{fill:none;stroke-width:0;}#mermaid-svg-TgiGbLyY0awoEcLt .icon-shape,#mermaid-svg-TgiGbLyY0awoEcLt .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-TgiGbLyY0awoEcLt .icon-shape p,#mermaid-svg-TgiGbLyY0awoEcLt .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-TgiGbLyY0awoEcLt .icon-shape .label rect,#mermaid-svg-TgiGbLyY0awoEcLt .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-TgiGbLyY0awoEcLt .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-TgiGbLyY0awoEcLt .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-TgiGbLyY0awoEcLt :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
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>
这里有几个坑点必须提前说:
| editorRef 用 ref 报错 | 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 模板:
| 润色 | 优化措辞、修正语病,保留原意 | 请润色以下文本,保持原意,使表达更流畅:${text} |
| 改写 | 换一种表达方式重写 | 请改写以下文本,换一种表达方式:${text} |
| 扩写 | 在原文基础上展开补充 | 请扩写以下文本,补充细节使内容更丰富:${text} |
| 缩写 | 精简压缩,保留核心信息 | 请缩写以下文本,保留核心信息,控制在原长一半:${text} |
| 撰写 | 根据标题/要点直接生成 | 请根据以下标题撰写一段内容,约300字:${text} |
2. 流式输出:为什么不用一次性返回
AI 生成几百字往往要 5-15 秒,如果走「等全部生成完再返回」的非流式模式,用户对着空白干等,体验很差。流式输出让 AI 像打字机一样逐字吐出来,用户立刻就能看到内容在长,体感延迟从「等 10 秒」变成「立刻有动静」。
先看流式和非流式的数据流差异:
#mermaid-svg-Ahjn7tgg8vTValvr{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Ahjn7tgg8vTValvr .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Ahjn7tgg8vTValvr .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Ahjn7tgg8vTValvr .error-icon{fill:#552222;}#mermaid-svg-Ahjn7tgg8vTValvr .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Ahjn7tgg8vTValvr .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Ahjn7tgg8vTValvr .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Ahjn7tgg8vTValvr .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Ahjn7tgg8vTValvr .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Ahjn7tgg8vTValvr .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Ahjn7tgg8vTValvr .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Ahjn7tgg8vTValvr .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Ahjn7tgg8vTValvr .marker.cross{stroke:#333333;}#mermaid-svg-Ahjn7tgg8vTValvr svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Ahjn7tgg8vTValvr p{margin:0;}#mermaid-svg-Ahjn7tgg8vTValvr .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-Ahjn7tgg8vTValvr .cluster-label text{fill:#333;}#mermaid-svg-Ahjn7tgg8vTValvr .cluster-label span{color:#333;}#mermaid-svg-Ahjn7tgg8vTValvr .cluster-label span p{background-color:transparent;}#mermaid-svg-Ahjn7tgg8vTValvr .label text,#mermaid-svg-Ahjn7tgg8vTValvr span{fill:#333;color:#333;}#mermaid-svg-Ahjn7tgg8vTValvr .node rect,#mermaid-svg-Ahjn7tgg8vTValvr .node circle,#mermaid-svg-Ahjn7tgg8vTValvr .node ellipse,#mermaid-svg-Ahjn7tgg8vTValvr .node polygon,#mermaid-svg-Ahjn7tgg8vTValvr .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Ahjn7tgg8vTValvr .rough-node .label text,#mermaid-svg-Ahjn7tgg8vTValvr .node .label text,#mermaid-svg-Ahjn7tgg8vTValvr .image-shape .label,#mermaid-svg-Ahjn7tgg8vTValvr .icon-shape .label{text-anchor:middle;}#mermaid-svg-Ahjn7tgg8vTValvr .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Ahjn7tgg8vTValvr .rough-node .label,#mermaid-svg-Ahjn7tgg8vTValvr .node .label,#mermaid-svg-Ahjn7tgg8vTValvr .image-shape .label,#mermaid-svg-Ahjn7tgg8vTValvr .icon-shape .label{text-align:center;}#mermaid-svg-Ahjn7tgg8vTValvr .node.clickable{cursor:pointer;}#mermaid-svg-Ahjn7tgg8vTValvr .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Ahjn7tgg8vTValvr .arrowheadPath{fill:#333333;}#mermaid-svg-Ahjn7tgg8vTValvr .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Ahjn7tgg8vTValvr .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Ahjn7tgg8vTValvr .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Ahjn7tgg8vTValvr .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Ahjn7tgg8vTValvr .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Ahjn7tgg8vTValvr .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Ahjn7tgg8vTValvr .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Ahjn7tgg8vTValvr .cluster text{fill:#333;}#mermaid-svg-Ahjn7tgg8vTValvr .cluster span{color:#333;}#mermaid-svg-Ahjn7tgg8vTValvr div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Ahjn7tgg8vTValvr .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Ahjn7tgg8vTValvr rect.text{fill:none;stroke-width:0;}#mermaid-svg-Ahjn7tgg8vTValvr .icon-shape,#mermaid-svg-Ahjn7tgg8vTValvr .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Ahjn7tgg8vTValvr .icon-shape p,#mermaid-svg-Ahjn7tgg8vTValvr .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Ahjn7tgg8vTValvr .icon-shape .label rect,#mermaid-svg-Ahjn7tgg8vTValvr .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Ahjn7tgg8vTValvr .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Ahjn7tgg8vTValvr .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Ahjn7tgg8vTValvr :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
流式
用户请求
服务器边生成边推送
前端边接收边渲染
非流式
用户请求
服务器生成全部
一次性返回 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 }
}
这段代码有几个关键设计:
4. 流式输出的时序
把整个流式过程画成时序图,谁调谁一目了然:
后端AI
useAIStream
组件
用户
后端AI
useAIStream
组件
用户
#mermaid-svg-NOcsLN2YEV9FcdqT{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-NOcsLN2YEV9FcdqT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-NOcsLN2YEV9FcdqT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-NOcsLN2YEV9FcdqT .error-icon{fill:#552222;}#mermaid-svg-NOcsLN2YEV9FcdqT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-NOcsLN2YEV9FcdqT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-NOcsLN2YEV9FcdqT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-NOcsLN2YEV9FcdqT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-NOcsLN2YEV9FcdqT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-NOcsLN2YEV9FcdqT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-NOcsLN2YEV9FcdqT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-NOcsLN2YEV9FcdqT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-NOcsLN2YEV9FcdqT .marker.cross{stroke:#333333;}#mermaid-svg-NOcsLN2YEV9FcdqT svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-NOcsLN2YEV9FcdqT p{margin:0;}#mermaid-svg-NOcsLN2YEV9FcdqT .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-NOcsLN2YEV9FcdqT text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-NOcsLN2YEV9FcdqT .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-NOcsLN2YEV9FcdqT .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-NOcsLN2YEV9FcdqT .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-NOcsLN2YEV9FcdqT .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-NOcsLN2YEV9FcdqT #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-NOcsLN2YEV9FcdqT .sequenceNumber{fill:white;}#mermaid-svg-NOcsLN2YEV9FcdqT #sequencenumber{fill:#333;}#mermaid-svg-NOcsLN2YEV9FcdqT #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-NOcsLN2YEV9FcdqT .messageText{fill:#333;stroke:none;}#mermaid-svg-NOcsLN2YEV9FcdqT .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-NOcsLN2YEV9FcdqT .labelText,#mermaid-svg-NOcsLN2YEV9FcdqT .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-NOcsLN2YEV9FcdqT .loopText,#mermaid-svg-NOcsLN2YEV9FcdqT .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-NOcsLN2YEV9FcdqT .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-NOcsLN2YEV9FcdqT .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-NOcsLN2YEV9FcdqT .noteText,#mermaid-svg-NOcsLN2YEV9FcdqT .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-NOcsLN2YEV9FcdqT .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-NOcsLN2YEV9FcdqT .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-NOcsLN2YEV9FcdqT .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-NOcsLN2YEV9FcdqT .actorPopupMenu{position:absolute;}#mermaid-svg-NOcsLN2YEV9FcdqT .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-NOcsLN2YEV9FcdqT .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-NOcsLN2YEV9FcdqT .actor-man circle,#mermaid-svg-NOcsLN2YEV9FcdqT line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-NOcsLN2YEV9FcdqT :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
选中文字,点「润色」
generate(text, 'polish')
POST { text, action } stream
data: {"delta":{"content":"润"}}
result.value += "润"
文字逐字显示
data: {"delta":{"content":"色"}}
result.value += "色"
文字继续长
data: [DONE]
loading = false
把结果插回编辑器
一句话总结:流式的本质不是「更快」,而是「让用户立刻看到有动静」,用即时反馈对抗等待焦虑。
五、把编辑器和 AI 接到一起:完整实战
1. 整体架构
两种编辑器接 AI 的套路略有不同,画张图先理清:
#mermaid-svg-LdsRx3uarIJzKxnL{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-LdsRx3uarIJzKxnL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-LdsRx3uarIJzKxnL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-LdsRx3uarIJzKxnL .error-icon{fill:#552222;}#mermaid-svg-LdsRx3uarIJzKxnL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-LdsRx3uarIJzKxnL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-LdsRx3uarIJzKxnL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-LdsRx3uarIJzKxnL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-LdsRx3uarIJzKxnL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-LdsRx3uarIJzKxnL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-LdsRx3uarIJzKxnL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-LdsRx3uarIJzKxnL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-LdsRx3uarIJzKxnL .marker.cross{stroke:#333333;}#mermaid-svg-LdsRx3uarIJzKxnL svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-LdsRx3uarIJzKxnL p{margin:0;}#mermaid-svg-LdsRx3uarIJzKxnL .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-LdsRx3uarIJzKxnL .cluster-label text{fill:#333;}#mermaid-svg-LdsRx3uarIJzKxnL .cluster-label span{color:#333;}#mermaid-svg-LdsRx3uarIJzKxnL .cluster-label span p{background-color:transparent;}#mermaid-svg-LdsRx3uarIJzKxnL .label text,#mermaid-svg-LdsRx3uarIJzKxnL span{fill:#333;color:#333;}#mermaid-svg-LdsRx3uarIJzKxnL .node rect,#mermaid-svg-LdsRx3uarIJzKxnL .node circle,#mermaid-svg-LdsRx3uarIJzKxnL .node ellipse,#mermaid-svg-LdsRx3uarIJzKxnL .node polygon,#mermaid-svg-LdsRx3uarIJzKxnL .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-LdsRx3uarIJzKxnL .rough-node .label text,#mermaid-svg-LdsRx3uarIJzKxnL .node .label text,#mermaid-svg-LdsRx3uarIJzKxnL .image-shape .label,#mermaid-svg-LdsRx3uarIJzKxnL .icon-shape .label{text-anchor:middle;}#mermaid-svg-LdsRx3uarIJzKxnL .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-LdsRx3uarIJzKxnL .rough-node .label,#mermaid-svg-LdsRx3uarIJzKxnL .node .label,#mermaid-svg-LdsRx3uarIJzKxnL .image-shape .label,#mermaid-svg-LdsRx3uarIJzKxnL .icon-shape .label{text-align:center;}#mermaid-svg-LdsRx3uarIJzKxnL .node.clickable{cursor:pointer;}#mermaid-svg-LdsRx3uarIJzKxnL .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-LdsRx3uarIJzKxnL .arrowheadPath{fill:#333333;}#mermaid-svg-LdsRx3uarIJzKxnL .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-LdsRx3uarIJzKxnL .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-LdsRx3uarIJzKxnL .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LdsRx3uarIJzKxnL .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-LdsRx3uarIJzKxnL .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LdsRx3uarIJzKxnL .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-LdsRx3uarIJzKxnL .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-LdsRx3uarIJzKxnL .cluster text{fill:#333;}#mermaid-svg-LdsRx3uarIJzKxnL .cluster span{color:#333;}#mermaid-svg-LdsRx3uarIJzKxnL div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-LdsRx3uarIJzKxnL .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-LdsRx3uarIJzKxnL rect.text{fill:none;stroke-width:0;}#mermaid-svg-LdsRx3uarIJzKxnL .icon-shape,#mermaid-svg-LdsRx3uarIJzKxnL .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LdsRx3uarIJzKxnL .icon-shape p,#mermaid-svg-LdsRx3uarIJzKxnL .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-LdsRx3uarIJzKxnL .icon-shape .label rect,#mermaid-svg-LdsRx3uarIJzKxnL .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LdsRx3uarIJzKxnL .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-LdsRx3uarIJzKxnL .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-LdsRx3uarIJzKxnL :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
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. 编辑器选区丢失
富文本编辑器点工具栏按钮会失焦,选区丢了。wangeditor 有 restoreSelection() 能恢复上一次选区,但前提是你没在中间做过其他选区操作。md-editor-v3 的 insert 是自己处理选区的,不用额外操心。
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爆款账号?
本篇博客文章唯一版权归属©波仔椿


