深度定制:在 Vue 3.5 应用中集成流式 AI 写作助手的实践

最近在开发一个多模态AI项目 ,里面有一个AI写作功能,就是将AI写作辅助功能集成到富文本编辑器中,该功能的交互方式方式灵活多变,需要思考清楚不同的使用场景和提升用户体验,这是实现该功能的难点。本文将深入探讨如何基于 Vue 3.5 和 wangEditor 富文本编辑器,实现一套高度定制化的流式 AI 写作助手。不仅会展示如何定义和注册一个专用的 AI 工具栏菜单,更会详细解析如何实现全局快捷键唤醒选中文本高亮标记流式请求处理 以及打字机效果等核心功能,最终打造出无缝、高效的 AI 辅助写作体验。

1. AI 写作助手的核心需求与技术选型

我们的目标是提供一个非侵入式、随用随取的 AI 助手,它能够根据用户的输入或选中的文本,执行续写、总结、润色和翻译等操作,并将结果以实时流式的方式展示给用户。

  • 富文本编辑器: 选择了 wangEditor,它提供灵活的模块注册机制和丰富的 API 接口,便于我们深度定制工具栏和内容操作。
  • 前端框架: 采用 Vue 3 (Composition API) ,配合 <script setup> 简化组件逻辑和状态管理。
  • AI 交互模式: 采用**弹出式(Popup)**设计,在光标位置或编辑器中心出现,提供极佳的上下文感知体验。

2. 定制 AI 工具栏菜单 (definedMenu.js)

首先,我们需要在 wangEditor 的工具栏中添加一个"AI 工具"下拉菜单。这通过实现一个自定义的菜单类来完成。

核心代码解析

javascript 复制代码
// definedMenu.js

import { Boot } from '@wangeditor/editor'

class MyselectAiBar {
    constructor() {
        this.title = 'AI 工具'
        this.tag = 'button'
        this.showDropPanel = true
    }
    // ... 其他方法(getValue, isActive, isDisabled, exec 保持默认)
    getPanelContentElem() {
        const ul = document.createElement('ul')
        // ... 省略 ul 和 li 的创建和样式设置

        const items = [
            { label: 'AI 续写', value: 'continue' },
            // ... 其他 AI 动作
        ]

        items.forEach((item) => {
            const li = document.createElement('li')
            li.textContent = item.label
            // 核心:点击并触发自定义全局事件
            li.addEventListener('click', (e) => {
                const event = new CustomEvent('askAiClick', {
                    detail: {
                        value: item.value, // continue, summary 等
                        type: 'toolbar',
                    },
                })
                document.dispatchEvent(event)
            })
            ul.appendChild(li)
        })
        return ul
    }
}

// 注册配置
const myselectAiConf = { key: 'myselectAiBar', factory() { return new MyselectAiBar() } }

function registerMenusOnce() {
    // 避免 Vite HMR 导致的重复注册
    if (globalThis.__aiMenusRegistered) return
    const module = { menus: [myselectAiConf] }
    Boot.registerModule(module)
    globalThis.__aiMenusRegistered = true
}

export class AIToolManager {
    init(editor) {
        registerMenusOnce()
        this.editor = editor
    }
    // ... destroy 方法
}

设计思路

  1. 菜单类型: this.showDropPanel = true 声明这是一个下拉面板菜单。
  2. 事件机制: 菜单项的点击事件不直接操作编辑器 ,而是通过 document.dispatchEvent(new CustomEvent('askAiClick', ...)) 触发一个自定义全局事件 。这种解耦方式非常关键,它使得 AI 菜单的逻辑可以在主组件 (NoteEditor.vue) 中集中处理,避免了在编辑器模块内部处理复杂的 Vue 组件逻辑。
  3. 单次注册: 使用 globalThis.__aiMenusRegistered 标记确保在 Vue 的热重载(HMR)机制下,编辑器模块只被注册一次。

3. 主编辑器组件 (NoteEditor.vue):集成与控制

NoteEditor.vue 负责初始化编辑器、注册 AI 菜单、处理快捷键,以及管理 AI 弹窗的显示状态和位置。

3.1 菜单与事件集成

NoteEditor.vue 中,我们首先将自定义菜单键 myselectAiBar 插入到工具栏配置中,并监听全局自定义事件。

javascript 复制代码
// NoteEditor.vue <script setup> 部分

// 工具栏配置
const toolbarConfig = {
    // ...
    insertKeys: {
        index: 0, // 插入到最前面
        keys: ['myselectAiBar']
    }
}

// 监听 AI 菜单点击事件
const handleAskAiClick = async (e) => {
    const detail = e?.detail || {}
    const action = detail.value || '' // continue/summary | polish | translate

    const editor = editorRef.value
    // ... 检查内容是否为空,弹出 ElMessage 提示

    // 1. 显示AI弹窗
    showAIPopup()

    // 2. 调用子组件处理(传入 AI 动作和全文内容)
    if (aiPopupRef.value) {
        // AI 菜单通常用于全文操作(如总结、续写),故传入全文
        const allContent = editor.getText() || ''
        aiPopupRef.value.handleSubmit(action, allContent)
    }
}

onMounted(() => {
    document.addEventListener('askAiClick', handleAskAiClick)
    // ... 监听其他事件
})

3.2 快捷键与光标定位

我们实现了按下 Ctrl + Alt 组合键来唤醒 AI 弹窗,并精确地将其定位到光标附近。

javascript 复制代码
// NoteEditor.vue <script setup> 部分

// 处理全局快捷键
const handleGlobalKeydown = (event) => {
    // 检测Ctrl+Alt组合键
    if (event.ctrlKey && event.altKey) {
        event.preventDefault()
        const editor = editorRef.value
        if (editor && editor.isFocused()) {
            showAIPopup() // 编辑器内,显示弹窗
        } else {
            showAITip() // 编辑器外,显示提示
        }
    }
}

// 获取光标像素坐标(考虑页面滚动)
const getCaretPixelPosition = () => {
    const selection = window.getSelection()
    if (!selection || selection.rangeCount === 0) return null
    const rect = selection.getRangeAt(0).getBoundingClientRect()
    // 关键:基于视口坐标加上滚动量,得到文档坐标
    return {
        x: rect.left + window.scrollX,
        y: rect.bottom + window.scrollY + 10, // 略微向下偏移
    }
}

// 缓存光标位置:在编辑器聚焦且选择变化时记录像素坐标
const updateCaretPosition = () => {
    // ... 省略逻辑
    const position = getCaretPixelPosition()
    if (position) lastCaretPosition.value = position
}

3.3 选中文本高亮与恢复

为了给用户清晰的视觉反馈,当用户选中一段文本唤出 AI 弹窗时,需要对该文本进行临时的高亮标记。

javascript 复制代码
// NoteEditor.vue <script setup> 部分

// 标记选中文本
const markSelectedText = () => {
    const selection = window.getSelection()
    if (!selection?.rangeCount || selection.getRangeAt(0).collapsed) return

    const range = selection.getRangeAt(0)
    selectedRange.value = range.cloneRange() // 缓存原始范围

    // 创建高亮包装元素 (span)
    const highlight = document.createElement('span')
    // ... 样式设置(背景色、边框等)
    
    try {
        // 提取选中内容并包装
        const contents = range.extractContents() // 移除选中内容
        highlight.appendChild(contents)
        range.insertNode(highlight) // 插入包装后的内容

        boldWrapper.value = highlight
        selection.removeAllRanges() // 清除选择,避免干扰
    } catch (error) {
        // ... 警告处理
    }
}

// 移除标记并恢复选中
const unmarkSelectedText = () => {
    if (!boldWrapper.value) return

    try {
        const strong = boldWrapper.value
        const parent = strong.parentNode

        // 将高亮元素的内容移回父节点
        while (strong.firstChild) {
            parent.insertBefore(strong.firstChild, strong)
        }

        // 移除高亮元素
        parent.removeChild(strong)

        // 恢复选中状态 (如果需要)
        // ...
        
        // 清理引用
        boldWrapper.value = null
        selectedRange.value = null
    } catch (error) {
        // ... 警告处理
    }
}

4. AI 弹窗组件 (AIWritingPopup.vue):流式交互实现

AIWritingPopup.vue 是 AI 助手的核心交互界面,负责用户输入、动作选择、调用 AI API 以及展示流式结果。

4.1 动作选择与输入逻辑

弹窗在初始状态会显示快捷动作菜单(续写、总结等)。用户可以选择一个动作,或直接输入指令。

vue 复制代码
<div v-if="dropdownVisible" class="dropdown-menu">
    <div v-for="action in quickActions" :key="action.value" class="dropdown-item"
        @click="handleActionSelect(action.value)">
        {{ action.label }}
    </div>
</div>

4.2 流式请求与打字机效果

为了提供更好的实时体验,AI 结果采用了流式传输,并配合打字机(Typewriter)效果模拟人打字的过程。

javascript 复制代码
// AIWritingPopup.vue <script setup> 部分

// 状态
const aiResult = ref('')       // 实时累积的 AI 完整结果
const displayText = ref('')    // 用于打字机效果显示的文本
const typewriterInterval = ref(null) // 定时器
const streamController = ref(null) // 流式请求的控制器

// 打字机效果
const startTypewriter = () => {
    // ... 确保 displayText 为空,重置 index
    typewriterInterval.value = setInterval(() => {
        const currentTargetText = aiResult.value

        if (index < currentTargetText.length) {
            // 实时追加字符
            displayText.value += currentTargetText[index]
            index++
        } else {
            // 检查是否有新流式内容到达,若没有则停止
            if (aiResult.value.length > index) {
                // 有新内容,继续
            } else {
                clearInterval(typewriterInterval.value)
                typewriterInterval.value = null
            }
        }
    }, 30) // 每 30ms 追加一个字符
}


// 核心:调用流式 API
const handleSubmit = async (externalAction = null, externalContent = null) => {
    // ... 参数检查和状态重置 (isLoading = true)

    try {
        streamController.value = aiAssistStream(
            requestParams,
            {
                onMessage: (content) => {
                    // 1. 实时更新完整结果
                    aiResult.value += content
                    // 2. 启动打字机
                    if (!typewriterInterval.value) {
                        startTypewriter()
                    }
                    isLoading.value = false // 收到内容后隐藏加载动画
                },
                onError: (error) => {
                    // ... 错误处理
                    isLoading.value = false
                    stopTypewriter()
                },
                onComplete: () => {
                    isLoading.value = false
                    displayText.value = aiResult.value // 确保完整显示
                    stopTypewriter()
                }
            }
        )

    } catch (error) {
        // ... 异常处理
    }
}

// 中断 AI 处理
const handleInterrupt = () => {
    if (streamController.value) {
        streamController.value.close() // 调用控制器中断流
        streamController.value = null
    }
    // ... 清理状态
}

4.3 结果插入与清理

AI 结果生成后,用户可以选择"插入到编辑器"或"复制结果"。插入时,需要将纯文本内容通过 NoteEditor.vue 暴露的事件接口 (insert-text) 传回主组件。

javascript 复制代码
// AIWritingPopup.vue <script setup> 部分

const handleInsertResult = () => {
    const content = aiResult.value || displayText.value
    if (content) {
        // 移除HTML标签,只保留纯文本
        const textContent = content.replace(/<[^>]*>/g, '')

        // 触发父组件的插入事件
        emit('insert-text', textContent)
        // ... 成功提示
    }
    handleClose() // 关闭弹窗
}

5. 总结

通过上述定制化方案,我们成功地将 AI 写作能力深度集成到了富文本编辑器中。关键的设计点在于:

  1. 解耦: 通过自定义全局事件 (askAiClick) 解耦了 wangEditor 菜单和 Vue 组件的业务逻辑。
  2. 用户体验: 实现了 Ctrl + Alt 快捷键唤醒光标定位 ,以及选中文本高亮,提供了强大的上下文感知能力。
  3. 实时性: 结合流式 API 请求打字机效果,极大地优化了 AI 结果的等待体验。

这一整套实现方案不仅提升了写作效率,也为后续更复杂的 AI 功能集成奠定了坚实的基础。

相关推荐
galaxylove3 小时前
Gartner发布数据安全态势管理市场指南:将功能扩展到AI的特定数据安全保护是DSPM发展方向
大数据·人工智能
格林威4 小时前
偏振相机在半导体制造的领域的应用
人工智能·深度学习·数码相机·计算机视觉·视觉检测·制造
心易行者4 小时前
10天!前端用coze,后端用Trae IDE+Claude Code从0开始构建到平台上线
前端
saadiya~4 小时前
ECharts 实时数据平滑更新实践(含 WebSocket 模拟)
前端·javascript·echarts
fruge5 小时前
前端三驾马车(HTML/CSS/JS)核心概念深度解析
前端·css·html
百锦再5 小时前
Vue Scoped样式混淆问题详解与解决方案
java·前端·javascript·数据库·vue.js·学习·.net
烛阴5 小时前
Lua 模块的完整入门指南
前端·lua
晓枫-迷麟5 小时前
【文献阅读】当代MOF与机器学习
人工智能·机器学习
来酱何人5 小时前
实时NLP数据处理:流数据的清洗、特征提取与模型推理适配
人工智能·深度学习·分类·nlp·bert