总结
- @microsoft/fetch-event-source 获取
text/event-stream流式数据 - typed.js 打字稿效果
crypto-js解析base64文本
当 AI 加入编辑器
我这里选用的是气泡栏插件,AI 显示的文本在气泡栏,然后点击气泡栏的按钮,AI 输入的文本会替换到编辑器中。

接入流式数据
后端 AI 接口返回的数据是流式数据,也就是text/event-stream这个头

原生 sse EventSource 连接只能对接 get 接口,有一定局限性
js
const eventSource = new EventSource('http://localhost:3000/stream')
eventSource.onmessage = ({ data }) => {
console.log('New message', JSON.parse(data))
}const eventSource = new EventSource('http://localhost:3000/stream')
eventSource.onmessage = ({ data }) => {
console.log('New message', JSON.parse(data))
}为了安全有些接口是 post 接口,这时候可以用微软的一个开源库@microsoft/fetch-event-source
js
import { fetchEventSource } from '@microsoft/fetch-event-source'
const url = `/api-myplus/myplus-qing/ug/ai/gc/document/friend?text=${encodeURIComponent(
'魅族'
)}`
let tar = ''
await fetchEventSource(url, {
method: 'POST',
onmessage(ev) {
const encodedData = ev.data
}
})import { fetchEventSource } from '@microsoft/fetch-event-source'
const url = `/api-myplus/myplus-qing/ug/ai/gc/document/friend?text=${encodeURIComponent(
'魅族'
)}`
let tar = ''
await fetchEventSource(url, {
method: 'POST',
onmessage(ev) {
const encodedData = ev.data
}
})base64 字符串解析
可能拿到的数据是 Base64 编码的字符串,所以要解析一下
js
import CryptoJS from 'crypto-js'
// 之前的代码
const decodedData = CryptoJS.enc.Base64.parse(encodedData).toString(
CryptoJS.enc.Utf8
)import CryptoJS from 'crypto-js'
// 之前的代码
const decodedData = CryptoJS.enc.Base64.parse(encodedData).toString(
CryptoJS.enc.Utf8
)实现打字稿效果
对接 ai 的数据,一般都喜欢用这个打字稿效果,所以这几步最后的代码大约是这样
tsx
import CryptoJS from 'crypto-js'
import Typed from 'typed.js'
const typed = useRef<Typed | null>(null)
const el1 = useRef(null)
const handleAI1 = async () => {
const url = `/api-myplus/myplus-qing/ug/ai/gc/document/friend?text=${encodeURIComponent(
'魅族'
)}`
let tar = ''
await fetchEventSource(url, {
method: 'POST',
onmessage(ev) {
const encodedData = ev.data // Base64 编码的字符串
// 解密 Base64 数据
const decodedData = CryptoJS.enc.Base64.parse(encodedData).toString(
CryptoJS.enc.Utf8
)
tar += decodedData
}
})
if (typed.current) {
typed.current?.destroy()
}
typed.current = new Typed(el1.current, {
strings: [tar],
typeSpeed: 50
})
setAiData(tar)
typed.current.start()
}
;<div className="type-wrap w-full">
<span style={{ whiteSpace: 'wrap' }} ref={el1}></span>
</div>import CryptoJS from 'crypto-js'
import Typed from 'typed.js'
const typed = useRef<Typed | null>(null)
const el1 = useRef(null)
const handleAI1 = async () => {
const url = `/api-myplus/myplus-qing/ug/ai/gc/document/friend?text=${encodeURIComponent(
'魅族'
)}`
let tar = ''
await fetchEventSource(url, {
method: 'POST',
onmessage(ev) {
const encodedData = ev.data // Base64 编码的字符串
// 解密 Base64 数据
const decodedData = CryptoJS.enc.Base64.parse(encodedData).toString(
CryptoJS.enc.Utf8
)
tar += decodedData
}
})
if (typed.current) {
typed.current?.destroy()
}
typed.current = new Typed(el1.current, {
strings: [tar],
typeSpeed: 50
})
setAiData(tar)
typed.current.start()
}
;<div className="type-wrap w-full">
<span style={{ whiteSpace: 'wrap' }} ref={el1}></span>
</div>解析选中的内容
应该还有更好或者简单的方案
要获取选中的内容,可以使用tiptap-markdown这个库。
下载
js
pnpm i tiptap-markdownpnpm i tiptap-markdown注入编辑器
js
import { Markdown } from 'tiptap-markdown'
export const extensions = [
Markdown.configure({
html: false,
transformCopiedText: true
})
]import { Markdown } from 'tiptap-markdown'
export const extensions = [
Markdown.configure({
html: false,
transformCopiedText: true
})
]在编辑器中使用
自今而后 text 就是选中的内容
js
const slice = editor.state.selection.content()
const text = editor.storage.markdown.serializer.serialize(slice.content)const slice = editor.state.selection.content()
const text = editor.storage.markdown.serializer.serialize(slice.content)气泡栏插件
气泡栏插件是基于@tiptap/extension-bubble-menu的,而这个插件是基于tippyjs的
下载
js
pnpm i @tiptap/extension-bubble-menupnpm i @tiptap/extension-bubble-menu注入编辑器
js
import BubbleMenu from '@tiptap/extension-bubble-menu'
export const extensions = [BubbleMenu]import BubbleMenu from '@tiptap/extension-bubble-menu'
export const extensions = [BubbleMenu]在编辑器中使用
这里给出部分代码做参考,需要注意的是tippyOptions这个配置,监听onCreate和onHidden,然后去改变气泡栏的位置
tsx
import { Editor, BubbleMenu } from '@tiptap/react'
import NormalBubble from './normal-bubble'
import AISelector from './ai-selector'
import { Fragment, useRef, useState } from 'react'
interface TiptapBubbleProps {
editor: Editor
open: boolean
onOpenChange: (open: boolean) => void
}
const TiptapBubble = ({ editor, open, onOpenChange }: TiptapBubbleProps) => {
let pos = 'top'
const instanceRef = useRef<any>(null)
const [selection, setSelection] = useState('')
const bubbleMenuProps = {
editor: editor,
tippyOptions: {
placement: pos as 'top',
onHidden: () => {
instanceRef.current.setProps({ placement: 'top' })
onOpenChange(false)
},
onCreate: (val: any) => {
instanceRef.current = val
},
moveTransition: 'transform 0.15s ease-out'
}
}
return (
<BubbleMenu
{...bubbleMenuProps}
className="w-max-[1000px] flex rounded-[4px] bg-[#ffffff] shadow-[rgba(15,15,15,0.05)_0px_0px_0px_1px,rgba(15,15,15,0.1)_0px_3px_6px,rgba(15,15,15,0.2)_0px_9px_24px]"
>
{open && <AISelector editor={editor} selection={selection}></AISelector>}
{!open && (
<Fragment>
<button
onClick={() => {
onOpenChange(true)
const state = editor.state
if (state) {
const { selection } = state
const text = state.doc.textBetween(
selection.from,
selection.to,
''
)
setSelection(text)
}
instanceRef.current.setProps({ placement: 'bottom-start' })
}}
className={`${
editor.isActive('bold') ? 'is-active' : ''
} border-r border-[#cccccc] px-[10px]`}
>
Ask AI
</button>
<NormalBubble editor={editor} setOpen={onOpenChange}></NormalBubble>
</Fragment>
)}
</BubbleMenu>
)
}
export default TiptapBubbleimport { Editor, BubbleMenu } from '@tiptap/react'
import NormalBubble from './normal-bubble'
import AISelector from './ai-selector'
import { Fragment, useRef, useState } from 'react'
interface TiptapBubbleProps {
editor: Editor
open: boolean
onOpenChange: (open: boolean) => void
}
const TiptapBubble = ({ editor, open, onOpenChange }: TiptapBubbleProps) => {
let pos = 'top'
const instanceRef = useRef<any>(null)
const [selection, setSelection] = useState('')
const bubbleMenuProps = {
editor: editor,
tippyOptions: {
placement: pos as 'top',
onHidden: () => {
instanceRef.current.setProps({ placement: 'top' })
onOpenChange(false)
},
onCreate: (val: any) => {
instanceRef.current = val
},
moveTransition: 'transform 0.15s ease-out'
}
}
return (
<BubbleMenu
{...bubbleMenuProps}
className="w-max-[1000px] flex rounded-[4px] bg-[#ffffff] shadow-[rgba(15,15,15,0.05)_0px_0px_0px_1px,rgba(15,15,15,0.1)_0px_3px_6px,rgba(15,15,15,0.2)_0px_9px_24px]"
>
{open && <AISelector editor={editor} selection={selection}></AISelector>}
{!open && (
<Fragment>
<button
onClick={() => {
onOpenChange(true)
const state = editor.state
if (state) {
const { selection } = state
const text = state.doc.textBetween(
selection.from,
selection.to,
''
)
setSelection(text)
}
instanceRef.current.setProps({ placement: 'bottom-start' })
}}
className={`${
editor.isActive('bold') ? 'is-active' : ''
} border-r border-[#cccccc] px-[10px]`}
>
Ask AI
</button>
<NormalBubble editor={editor} setOpen={onOpenChange}></NormalBubble>
</Fragment>
)}
</BubbleMenu>
)
}
export default TiptapBubble