《npm包》文本流分割器

使用场景

  • 在后端开发中,服务器通过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
相关推荐
guojl几秒前
深度剖析Kafka读写机制
前端
FogLetter1 分钟前
图片懒加载:让网页飞起来的魔法技巧 ✨
前端·javascript·css
Mxuan2 分钟前
vscode webview 插件开发(精装篇)
前端
Mxuan3 分钟前
vscode webview 插件开发(交付篇)
前端
Mxuan4 分钟前
vscode 插件与 electron 应用跳转网页进行登录的实践
前端
拾光拾趣录4 分钟前
JavaScript 加载对浏览器渲染的影响
前端·javascript·浏览器
Codebee4 分钟前
OneCode图表配置速查手册
大数据·前端·数据可视化
然我5 分钟前
React 开发通关指南:用 HTML 的思维写 JS🚀🚀
前端·react.js·html
Mxuan6 分钟前
vscode webview 插件开发(毛坯篇)
前端
FogLetter8 分钟前
前端性能优化:深入理解回流与重绘
前端·css