49、发起流式请求获取回答

第一步:点击发送按钮和回车键发送消息 调用ask()

// 修改发送按钮的点击处理
function ask() {
  if (store.replying) {
    // message.warning('请等待上一次提问完成')
    return
  }
  if (editableInput.value?.textContent === '' || editableInput.value?.textContent === null) {
    message.warning('请输入内容')
    return
  }
  // 如果存在正在上传的文件,阻止提问
  if (isUploading.value) {
    message.warning('文件上传完成前不能发送提问')
    return
  }

  // 保存输入内容
  store.questionInput = editableInput.value?.textContent || ''
  store.questionInputFiles = questionInputFiles.value.concat(uploadedFiles.value) || []
  // store.currentChatId = new Date().getTime().toString()
  store.askQuestion()
  // 清空内容和相关建议列表
  if (editableInput.value) {
    editableInput.value.innerHTML = ''
    uploadedFiles.value = []
  }
}

 第二步在ask()函数中调用  store.askQuestion()

 async askQuestion(question?: string) {
      this.showInputUpload = true
      const { questionInput, currentSession, replying, currentChatId, questionInputFiles } = this
      const inputVal = questionInput.replace(/^\s+|\s+$/g, '')
      if (!inputVal && !question) {
        return
      }
      // 如果回答中不允许再次问答
      if (replying)
        return

      /** 有预定问题用预定问题,否则用输入框的问题 */
      if (!question) {
        question = questionInput
        this.questionInput = ''
      }

      if (!question)
        return
      const timestamp = Date.now()

      currentSession?.msgList.push({ type: 'question', content: question, time: getTime(), timestamp, currentChatId: currentChatId, files: questionInputFiles })
      currentSession?.msgList.push({ type: 'answer', question, time: '', loading: true, timestamp: timestamp + 1, currentChatId: currentChatId })

      this.scroll2Bottom?.(true)
    },

第三步:answer组件渲染,调用getAnswer(props.msg);

 AnswerNew.vue

if (!props.msg.result) {
  store.getAnswer(props.msg)
}

 getAnswer 方法主要用于处理获取流式回答的逻辑。它会发起一个流式请求,对服务器返回的流式数据进行解析和处理,同时支持中途停止请求。 

 async getAnswer(msgAnswer: MsgAnswer) {
      console.log(msgAnswer,'msgAnswer')
      // 省略不必要的变量
      //创建 AbortController 实例,用于后续取消请求
      const abortController = new AbortController();

      //为 currentSession 的 stopReplyFn 赋值,以便在外部调用时能取消请求。
      this.currentSession!.stopReplyFn = () => {
        abortController.abort()
      }

      // 初始化 msgAnswer 的相关属性,表示开始获取回答。
      msgAnswer.result = { text: '', type: 'text', id: '', message_id: '', task_id: '', conversation_id: '' }
      msgAnswer.time = getTime()
      msgAnswer.generating = true
      msgAnswer.loading = true

      // 也可以在 msgAnswer 上存一个已解析好的 chunks 数组,或者
      // 让前端组件使用 msgAnswer.result!.text 的方式展示也行
      // 示例:在 msgAnswer 上挂一个 parsedChunks
      //若 msgAnswer 没有 parsedChunks 属性,则初始化该属性为一个空数组,
      //用于存储解析后的块数据。
      if (!(msgAnswer as any).parsedChunks) {
        (msgAnswer as any).parsedChunks = []
      }


      // 判断文本中是否包含###,若包含就当 markdown 处理,否则当普通text
      const convertToTextOrMarkdownChunk = (text: string) => {
        const isMarkdown = text.includes('###')
        return {
          type: isMarkdown ? 'markdown' : 'text',
          content: text,
        }
      }


      // parseOneChunk:只解析开头是否能构成一个完整 <data> / <chart> 块,或纯文本/markdown 块
      // 若能返回 {parsed, remaining},否则返回 null
      const parseOneChunk = (text: string) => {
        // 正则同时捕捉 <data>...</data>、<chart>...</chart>、<sql>...</sql>
        const tagRegex = /<(data|chart|sql)>([\s\S]*?)<\/\1>/
        const match = tagRegex.exec(text)
        if (!match) {
          // 如果没匹配到,可能是纯文本或未完整标签
          const potentialTagIndex
            = text.includes('<data>')
              || text.includes('<chart>')
              || text.includes('<sql>')

          // 如果已经出现任意标签开头,但没有完整闭合,说明还没接收完,等下次
          if (potentialTagIndex) {
            return null
          }

          // 否则全部作为文本
          if (!text)
            return null
          return {
            parsed: convertToTextOrMarkdownChunk(text),
            remaining: '',
          }
        }

        // 如果标签前有文本,就先拆出来当文本/markdown 块
        if (match.index > 0) {
          const textBeforeTag = text.substring(0, match.index)
          if (textBeforeTag.trim()) {
            return {
              parsed: convertToTextOrMarkdownChunk(textBeforeTag),
              remaining: text.substring(match.index),
            }
          }
        }

        // 匹配到的整个标签,如 <data>{"x":1}</data>
        const wholeTag = match[0]
        // "data" / "chart" / "sql"
        const tagType = match[1]
        // 标签内的内容
        const contentString = match[2]

        let chunk
        if (tagType === 'data' || tagType === 'chart') {
          // 对 data/chart 尝试 JSON.parse
          try {
            const content = JSON.parse(contentString)
            chunk = { type: tagType, content }
          }
          catch {
            // JSON 不完整,等待下一波数据
            return null
          }
        }
        else if (tagType === 'sql') {
          // 假设 sqlContent 是 match[2] 的原文
          const codeBlock = `\`\`\`sql\n${contentString}\n\`\`\``
          chunk = { type: 'markdown', content: codeBlock }
        }

        // 计算标签后的剩余字符串
        const nextPos = match.index + wholeTag.length
        const remaining = text.substring(nextPos)
        return { parsed: chunk, remaining }
      }
      // handleStreamData:每次收到后端的一行文本,就调这里做增量解析

      const handleStreamData = (newText: string) => {
        // 1. 先把 newText 累加到 buffer
        (msgAnswer as any)._unparsedBuffer += newText

        // 2. 尝试不断从 buffer 里解析块
        let chunk
        do {
          chunk = parseOneChunk((msgAnswer as any)._unparsedBuffer)
          if (chunk) {
            // 2.1 解析成功,则更新 buffer、并把解析出的 chunk 加入 msgAnswer
            (msgAnswer as any)._unparsedBuffer = chunk.remaining
            ;(msgAnswer as any).parsedChunks.push(chunk.parsed)
          }
        } while (chunk)
      }
      // ---------------------------------------------------------------------
      // 开始发起流式请求
      const streamAbortController = new AbortController()
      let uploadFiles =  this.questionInputFiles.length > 0 ? this.questionInputFiles.map((item) => ({
        // ...item,
          type: item.type,
          transfer_method: 'local_file',
          upload_file_id: item.fileId
        })) : []
      fetchStreamText(
        {
          question: msgAnswer.question,
          sessionId: this.currentSessionId,
          files: uploadFiles,
          agentId: ChatInfoObject.agentId,
          user: ChatInfoObject.userId,
          conversation_id: this.currentChatId
         },
        // 第三个参数: 每次后端返回一行新数据就会调用
        (str, formattedResults) => {
          // 调用 handleStreamData 做增量解析
          handleStreamData(str)
          // 如果你仍然需要在 msgAnswer.result!.text 里拼接全部文本
          // (比如回退兼容旧逻辑),可以保留这一句
          msgAnswer.result!.text += str
          msgAnswer.result!.id = formattedResults[0].id
          msgAnswer.result!.message_id = formattedResults[0].message_id
          msgAnswer.result!.task_id = formattedResults[0].task_id
          msgAnswer.result!.conversation_id = formattedResults[0].conversation_id
          this.currentChatId = formattedResults[0].conversation_id


        },
        // 第四个参数: 流式结束(包括自然结束或报错)时执行
        () => {
          msgAnswer.loading = false
          msgAnswer.generating = false
          this.scrolledBottom = true
          this.currentSession!.stopReplyFn = undefined
        },
        // 第五个参数: 流式错误
        (err) => {
          msgAnswer.loading = false
          msgAnswer.generating = false
          this.scrolledBottom = true
          this.currentSession!.stopReplyFn = undefined
          // message.error('流式请求失败')
        },
        // 传递 AbortSignal,用于中途取消流
        streamAbortController.signal,
      )
        .then(() => {
        })
        .catch((err) => {
        })

      // 设置stopReplyFn,用于在外部想中断请求时调用
      this.currentSession!.stopReplyFn = () => {
        streamAbortController.abort()
        msgAnswer.generating = false
        msgAnswer.loading = false
      }
      this.scroll2Bottom?.(true)
    },

getanswer()调用fetchStreamText()

export function fetchStreamText(
  data: Record<string, any> | undefined,
  onAdd: (str: string) => void,
  onFinish: (str: string) => void,
  onError: (err: Error) => void,
  signal?: AbortSignal,
) {
  if (!data?.question) {
    throw new Error('Question is required')
  }
  return request.postEventStream('/reModuleHomePage/chat-messages-stream', {
    inputs: {},
    query: data.question,
    response_mode: "streaming",
    conversation_id: data.conversation_id,
    user: data.user,
    agentId: data.agentId,
    files: data.files
  }, { onAdd, onFinish, onError }, { signal })
}

3、 fetchStreamText()该方法用于发起一个流式的 POST 请求,并处理服务器响应。下面逐行解释这段代码:

  async postEventStream(url: string, body: Record<string, any>, 
methods: StreamMethods, config: RequestConfig) {
    //new Headers() 创建一个新的 Headers 对象,用于存储请求头信息。
    const headers = new Headers();

    let und
    headers.set('X-CSRF-TOKEN', und)
    headers.set('Content-Type', 'application/json')
    headers.set('Accept', 'application/json, text/plain, */*')
    headers.set('Cookie', 'SESSION=603feaca-fa9f-4b61-9444-b96620fb3eaf;
 JSESSIONID=AF3489505A1AAE85600A593A85F1A0CE')

    // 如何输出查看 headers 的值
    //headers.entries() 返回一个迭代器,用于遍历 Headers 对象中的所有键值对。
    for (const [key, value] of headers.entries()) {
      console.log(`%c${key}:`, 'color: blue;', value); // 高亮键名[3](@ref)
    }

    await fetch(this.baseURL + url, {
      method: 'POST',
      headers: {
        'X-CSRF-TOKEN': und,
        'Content-Type': 'application/json',
        'Accept': 'application/json, text/plain, */*'
      },
      body: JSON.stringify(body),
      ...config,
    }).then(async (res) => {
      if (res.ok) {
        await this.handleStream(res, methods)
      }
      else {
        throw new Error('服务器响应异常')
      }
    }).catch((err) => {
      methods.onError?.(err)
    })

  }

4、这部分代码定义了 handleStream 方法,其主要功能是处理流式响应体。

流式响应指的是服务器不会一次性把所有数据返回,而是分多次、持续地发送数据,这种方式常用于实时数据传输场景,比如聊天机器人的实时回复。下面为你简单总结这个方法的核心工作流程:

- 准备工作

- 获取响应体的读取器,用于逐块读取二进制数据。
- 创建解码器,把读取到的二进制数据转换为字符串。
- 初始化一个缓冲区,用来存储可能不完整的数据块。
- 从传入的参数中解构出 onAdd 、 onEvent 和 onFinish 这三个回调函数,后续会在不同情况下调用它们。
- 循环读取数据 :

- 进入一个递归函数,不断从响应体读取数据块,直到数据读取完毕。
- 每读取一个数据块,就用解码器将其转为字符串并添加到缓冲区。
- 数据拆分与处理 :

- 按换行符把缓冲区中的字符串拆分成多行。
- 保留最后一行,因为它可能不完整,等待下一次读取数据时补充完整。
- 遍历拆分后的每一行数据,只处理以 data: 开头且不是 data: [DONE] 的行。
- 数据解析与格式化 :

- 去掉每行数据前面的 data: 前缀和多余空格,得到 JSON 格式的字符串。
- 尝试把 JSON 字符串解析成 JavaScript 对象。
- 如果解析成功且对象的 event 字段为 message ,就提取其中的 answer 内容,判断数据是新增数据( add 事件)还是结束数据( finish 事件)。
- 把处理后的数据格式化成特定结构,存储到一个数组里。
- 调用回调函数 :

- 把格式化后的数据里的文本内容合并起来,调用 onAdd 回调函数,通知外部有新数据到来。
- 调用 onEvent 回调函数,传递最后一条处理后的数据和所有格式化数据,让外部可以处理事件相关逻辑。
- 如果遇到结束事件,调用 onFinish 回调函数,通知外部流式数据传输结束。

 async handleStream(response: Response, methods: StreamMethods) {
    //获取响应体的读取器,用于逐块读取数据,用于从响应体里逐块读取二进制数据。
    const reader = response.body!.getReader();

    //将二进制数据解码为字符串。
    const decoder = new TextDecoder();

    //用于存储未完整的文本数据,因为数据可能是分块传输的
    let buffer = ''

    const { onAdd, onEvent, onFinish } = methods;

    //定义递归读取数据的函数
    async function readData() {

      //异步读取一块数据,返回一个包含 done 和 value 的对象。 done 表示是否读取完所有数据, 
      //value 是读取到的二进制数据。
      const { done, value } = await reader.read()
      if (done)
        return

      // 将二进制数据解码为字符串,并将其添加到 buffer 中
      buffer += decoder.decode(value, { stream: true });

      // 按换行符拆分,最后一行可能是不完整的,保留到 buffer 中
      const lines = buffer.split('\n');

      //将最后一行数据从 lines 数组中取出并赋值给 buffer ,因为最后一行可能不完整。
      buffer = lines.pop() || '';

      const formattedResults = [];

      //遍历每行数据并解析
      for (const line of lines) {

        // 只处理以 "data:" 开头且不为 "[DONE]" 的行
        if (line.startsWith('data:') && line.trim() !== 'data: [DONE]') {
          // 去除所有 "data:" 前缀及空白字符
          const jsonStr = line.replace(/^(?:data:\s*)+/, '').trim();

          // 如果 jsonStr 为空,则跳过当前循环
          if (jsonStr === '') {
            continue
          }

          try {
            const parsed = JSON.parse(jsonStr)
            console.log(parsed);
            if(parsed.event === 'message'){
            // 处理 parsed 数据,计算出描述内容及事件类型
            let desc = ''
            let type = 'text' // 默认为文本
            let computedEvent = 'add'
            if (parsed && parsed.answer) {
              desc += parsed.answer
            }
            // 如果存在 finish_reason,则认为是结束事件
            if (!parsed.answer || parsed.answer === '') {
              computedEvent = 'finish'
            }
            // 构造传给 onEvent 的数据格式:data 为 JSON 字符串,解析后应包含 desc 字段
            const formatted = {
              event: computedEvent,
              data: JSON.stringify({ desc, type, text: desc }),
              ...parsed
            }
            formattedResults.push(formatted)

            // 如果是 finish 事件,可立即调用 onFinish
            if (computedEvent === 'finish') {
              onFinish?.(JSON.stringify({ desc, type, text: desc }))
            }
          }
          }
          catch (error) {
            console.error('JSON 解析错误:', error, jsonStr)
          }
        }
      }

      // 将本次所有解析的内容合并用于 onAdd 回调
      const accumulatedContent = formattedResults.reduce((acc, item) => {
        const parsedData = JSON.parse(item.data)
        return acc + (parsedData.desc || '')
      }, '')
      if (accumulatedContent) {
        onAdd?.(accumulatedContent, formattedResults)
      }
      // 调用 onEvent,传入最后一条事件以及所有格式化后的结果
      if (formattedResults.length) {
        onEvent?.(formattedResults[formattedResults.length - 1], formattedResults)
      }

      await readData()
    }

    await readData()
  }
// 添加一个状态变量来跟踪当前是否在 think 标签内
let content = ''
watch(
  () => props.msg.result,
  (newResults: any) => {
    if (!newResults || !Array.isArray(newResults)) return
    newResults.forEach((result) => {
      content += result.answer
      processStreamingContent(result.answer)
    })
    debounce(() => {
      store.scroll2Bottom?.(true,'smooth')
    }, 100)()
  },
  { immediate: true, deep: true }
)

 该函数 processStreamingContent 用于解析流式内容,识别并处理开始标签、结束标签以及普通文本,将内容按类型分块存储到 bufferQueue 或 parsedChunks 中

// 处理流式输出内容的函数
// 当前标签类型
const currentTagType = ref<string | null>(null);
// 当前标签缓冲区
const tagBuffer = ref<string>('');
// 当前标签对应的 chunk ID
const currentTagChunkId = ref<number | null>(null);

function processStreamingContent(content: string) {
  // if (!content) return
  // console.log(currentTagType.value, content);
  // 若当前未在标签内,检查开始标签
  if (!currentTagType.value) {
    const startTagMatch = content.match(/<(\w+)>/)
    if (startTagMatch) {
      const tagType = startTagMatch[1]
      const startIndex = startTagMatch.index!

      // 处理标签前的普通文本
      if (startIndex > 0) {
        bufferQueue.value.push({
          type: 'text',
          content: content.slice(0, startIndex),
          loading: true
        })
      }

      currentTagType.value = tagType
      tagBuffer.value = ''

      // 创建一个新的 chunk 并记录其 ID
      bufferQueue.value.push({
        type: tagType,
        content: '',
        loading: true
      })
      // 记录当前处理的 chunk 在 parsedChunks 中的位置
      currentTagChunkId.value = parsedChunks.value.length + bufferQueue.value.length - 1

      // 递归处理标签后的内容
      const remainingContent = content.slice(startIndex + tagType.length + 2)
      processStreamingContent(remainingContent)
    } else {
      // 没有标签,视作普通文本
      bufferQueue.value.push({
        type: 'text',
        content,
        loading: true
      })
    }
  } else {
    // 当前在标签内,检查对应的结束标签
    const endTagRegex = new RegExp(`<\\/${currentTagType.value}>`)
    const endTagMatch = content.match(endTagRegex)

    if (endTagMatch) {
      const endIndex = endTagMatch.index!

      // 结束标签前内容加入缓冲区
      tagBuffer.value += content.slice(0, endIndex)

      // 更新已存在的 chunk 内容
      if (currentTagChunkId.value !== null) {
        // 如果 chunk 已经被渲染到 parsedChunks 中
        if (currentTagChunkId.value < parsedChunks.value.length) {
          parsedChunks.value[currentTagChunkId.value].content = tagBuffer.value
        } else {
          // 如果 chunk 还在 bufferQueue 中
          const bufferIndex = currentTagChunkId.value - parsedChunks.value.length
          if (bufferIndex >= 0 && bufferIndex < bufferQueue.value.length) {
            bufferQueue.value[bufferIndex].content = tagBuffer.value
          }
        }
      }

      const currentTag = currentTagType.value

      // 重置状态
      currentTagType.value = null
      tagBuffer.value = ''
      currentTagChunkId.value = null

      // 递归处理结束标签后的内容
      const remainingContent = content.slice(endIndex + currentTag.length + 3)
      if (remainingContent) {
        processStreamingContent(remainingContent)
      }
    } else {
      // 未找到结束标签,继续累积内容并实时更新
      tagBuffer.value += content

      // 实时更新对应 chunk 的内容
      if (currentTagChunkId.value !== null) {
        // 如果 chunk 已经被渲染到 parsedChunks 中
        if (currentTagChunkId.value < parsedChunks.value.length) {
          parsedChunks.value[currentTagChunkId.value].content = tagBuffer.value
        } else {
          // 如果 chunk 还在 bufferQueue 中
          const bufferIndex = currentTagChunkId.value - parsedChunks.value.length
          if (bufferIndex >= 0 && bufferIndex < bufferQueue.value.length) {
            bufferQueue.value[bufferIndex].content = tagBuffer.value
          }
        }
      }
    }
  }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值