第一步:点击发送按钮和回车键发送消息 调用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
}
}
}
}
}
}