使用场景
- 在后端开发中,服务器通过SSE向客户端发送响应时,文本内容常常是分块传输的。例如,一个长文本可能被拆分成多个片段,逐块发送给客户端。然而,这些片段在传输过程中可能会被截断,导致语义不完整或标点符号被错误分割。为了将这些分块的文本重新组合成完整且有意义的段落,需要一个工具来智能地收集和处理这些片段。
安装
使用 pnpm
进行安装
bash
pnpm install @minto-ai/text-stream-slicer
使用
引入库
javascript
import TextStreamSlicer from '@minto-ai/text-stream-slicer'
创建应用实例
javascript
const textStreamSlicer = new TextStreamSlicer()
可用方法
分割文本
处理输入文本,返回分段后的文本数组。
javascript
console.log(textStreamSlicer.processText('你好呀。'))
console.log(textStreamSlicer.processText(`抱我吗?`, true))
参数
text (string)
: 输入文本内容。[includeRemaining=false] (boolean)
: 是否包含未完成的段落。
返回
(Array)
: 分段后的文本数组。
示例代码
javascript
import TextStreamSlicer from '@minto-ai/text-stream-slicer'
const textStreamSlicer = new TextStreamSlicer()
const arrText = [
`你要抱`,
`抱我吗?`,
`我好`,
`喜欢你呀!`,
]
let index = 0
const timer = setInterval(() => {
console.log(textStreamSlicer.processText(arrText[index], index === arrText.length - 1))
if (index === arrText.length - 1) {
clearInterval(timer)
}
index += 1
}, 100)
实现源码
typescript
enum TokenTypeEnum {
SPACE_CHAR = 'spaceChar',
CHINESE_CHAR = 'chineseChar',
CHINESE_PUNCTUATION = 'chinesePunctuation',
ENGLISH_WORD = 'englishWord',
ENGLISH_PUNCTUATION = 'englishPunctuation',
UNKNOWN_CHAR = 'unknownChar',
}
interface Token {
type: TokenTypeEnum
value: string
}
/**
* 文本流分段器 - 用于将连续文本按语言规则智能分割成有意义的段落和片段
* 支持中文、英文混合文本处理,正确识别单词、标点符号和数字,避免小数点等特殊符号导致的错误分段
*/
class TextStreamSplitter {
// 中文字符
private readonly CHINESE_CHAR_REGEX = /[\u4E00-\u9FA5]/
// 中文标点符号
private readonly CHINESE_PUNCTUATION_REGEX = /[。?!,;:""''()---...、·]/
// // 中文段落结束符号(句末标点)
private readonly CHINESE_PARAGRAPH_END_REGEX = /[。?!;]/
// 英文单词起始字符
private readonly ENGLISH_WORD_START_REGEX = /[a-z]/i
// 英文单词延续字符(含缩写和连字符)
private readonly ENGLISH_WORD_CONTINUE_REGEX = /[a-z''-]/i
// 英文标点符号
private readonly ENGLISH_PUNCTUATION_REGEX = /[.,!?;:'"()-]/
// 英文段落结束符号(句末标点)
private readonly ENGLISH_PARAGRAPH_END_REGEX = /[.!?;]/
// 空白字符(统一转为单个空格)
private readonly SPACE_CHAR_REGEX = /\s/
// 最小段落长度
private readonly MIN_PARAGRAPH_LENGTH = 10
// 待处理的剩余文本标记
private pendingTokens: Token[] = []
/**
* 将输入文本转换为标记流。
* @param text 原始文本内容。
* @returns 标记化后的文本标记数组。
*/
private tokenizeText(text: string): Token[] {
const tokens: Token[] = []
let currentIndex = 0
while (currentIndex < text.length) {
const char = text[currentIndex]
// 识别中文字符
if (this.CHINESE_CHAR_REGEX.test(char)) {
tokens.push({
type: TokenTypeEnum.CHINESE_CHAR,
value: char,
})
}
// 识别英文单词
else if (this.ENGLISH_WORD_START_REGEX.test(char)) {
const startIndex = currentIndex
let endIndex = currentIndex + 1
// 提取完整英文单词
while (endIndex < text.length && this.ENGLISH_WORD_CONTINUE_REGEX.test(text[endIndex])) {
endIndex++
}
const word = text.substring(startIndex, endIndex)
tokens.push({
type: TokenTypeEnum.ENGLISH_WORD,
value: word,
})
// 跳过已处理的字符
currentIndex += word.length - 1
}
// 识别中文标点符号
else if (this.CHINESE_PUNCTUATION_REGEX.test(char)) {
tokens.push({
type: TokenTypeEnum.CHINESE_PUNCTUATION,
value: char,
})
}
// 识别英文标点符号
else if (this.ENGLISH_PUNCTUATION_REGEX.test(char)) {
tokens.push({
type: TokenTypeEnum.ENGLISH_PUNCTUATION,
value: char,
})
}
// 识别空白字符
else if (this.SPACE_CHAR_REGEX.test(char)) {
tokens.push({
type: TokenTypeEnum.SPACE_CHAR,
value: char,
})
}
// 未知字符类型
else {
tokens.push({
type: TokenTypeEnum.UNKNOWN_CHAR,
value: char,
})
}
currentIndex++
}
return tokens
}
/**
* 文本净化预处理 - 去除无效字符和格式。
* @param text 原始文本。
* @returns 净化后的文本。
*/
private sanitizeText(text: string): string {
return text
.trim() // 去除首尾空白
.replace(/\s+/g, ' ') // 将连续空白字符转为单个空格
.replace(/(\d+)\s+/g, '$1') // 去除数字后的空白
.replace(/\*+/g, '') // 移除星号
.replace(/[\u{1F600}-\u{1F64F}]/gu, '') // 移除表情符号
}
/**
* 判断标记是否为段落结束符号。
* @param token 文本标记。
* @returns 是否为段落结束符号。
*/
private isParagraphEndToken(token: Token): boolean {
return (
this.CHINESE_PARAGRAPH_END_REGEX.test(token.value)
|| this.ENGLISH_PARAGRAPH_END_REGEX.test(token.value)
)
}
/**
* 将标记数组转换回文本。
* @param tokens 文本标记数组。
* @returns 组合后的文本。
*/
private tokensToString(tokens: Token[]): string {
return tokens.map(token => token.value).join('')
}
/**
* 将标记流分割成多个段落。
* @param tokens 文本标记数组。
* @returns 分段后的标记数组集合。
*/
private splitToParagraphs(tokens: Token[]): Token[][] {
const paragraphs: Token[][] = []
let currentParagraph: Token[] = []
for (const token of tokens) {
currentParagraph.push(token)
// 满足最小长度且遇到段落结束符号时分割段落
if (
currentParagraph.length >= this.MIN_PARAGRAPH_LENGTH
&& this.isParagraphEndToken(token)
) {
paragraphs.push(currentParagraph)
currentParagraph = []
}
}
// 添加剩余的文本
if (currentParagraph.length > 0) {
paragraphs.push(currentParagraph)
}
return paragraphs
}
/**
* 合并因小数点导致的错误分段。
* @param paragraphs 分段后的标记数组集合。
* @returns 合并后的标记数组集合。
*/
private mergeDecimalParagraphs(paragraphs: Token[][]): Token[][] {
return paragraphs.reduce((mergedParagraphs: Token[][], currentParagraph: Token[]) => {
if (mergedParagraphs.length === 0) {
mergedParagraphs.push(currentParagraph)
}
else {
const lastParagraph = mergedParagraphs[mergedParagraphs.length - 1]
const lastParagraphText = this.tokensToString(lastParagraph)
const currentParagraphText = this.tokensToString(currentParagraph)
// 如果上一段以数字加小数点结尾,且当前段以数字开头,则合并两段
if (/\d+\.$/.test(lastParagraphText) && /^\d+/.test(currentParagraphText)) {
mergedParagraphs[mergedParagraphs.length - 1] = [...lastParagraph, ...currentParagraph]
}
else {
mergedParagraphs.push(currentParagraph)
}
}
return mergedParagraphs
}, [])
}
/**
* 处理输入文本,返回分段后的文本数组。
* @param text 输入文本内容。
* @param includeRemaining 是否包含未完成的段落。
* @returns 分段后的文本数组。
*/
public processText(text: string, includeRemaining: boolean = false): string[] {
// 净化并标记化文本
const tokens = this.tokenizeText(this.sanitizeText(text))
// 合并历史遗留的未完成段落
const combinedTokens = [...this.pendingTokens, ...tokens]
// 分段并处理小数点问题
const paragraphs = this.mergeDecimalParagraphs(this.splitToParagraphs(combinedTokens))
// 处理未完成的段落
if (paragraphs.length > 0) {
const lastParagraph = paragraphs[paragraphs.length - 1]
const lastToken = lastParagraph[lastParagraph.length - 1]
if (includeRemaining) {
// 返回所有内容,清空待处理标记
this.pendingTokens = []
}
else {
// 检查最后一段是否完整
if (this.isParagraphEndToken(lastToken)) {
// 完整段落但以数字加小数点结尾,视为不完整
if (/\d+\.$/.test(this.tokensToString(lastParagraph))) {
this.pendingTokens = paragraphs.pop()!
}
else {
this.pendingTokens = []
}
}
else {
// 不完整段落,保留待处理
this.pendingTokens = paragraphs.pop()!
}
}
}
// 返回分段后的文本
return paragraphs.map(paragraph => this.tokensToString(paragraph))
}
}
export default TextStreamSplitter