欢迎光临
我们一直在努力

前端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 文本(.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 模板:

能力作用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 }
}

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

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


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

    赞(0)
    未经允许不得转载:171主机测评 » 前端AI编辑器实现:一键润色与智能写作
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址