Deepseek返回数据打字机效果处理方式
文章类型:Vue
发布者:hp
发布时间:2026-07-01
打字机效果通过两个独立的队列实现思考过程和回答内容的逐字显示,并且思考过程完成后才显示回答内容。核心是队列管理 + 定时器 + 增量文本处理。
1. 消息对象结构
interface ChatMessage {
id: number // 消息唯一ID
role: 'user' | 'bot' // 角色
content: string // 回答内容(逐字累积)
thinking?: string // 思考过程(逐字累积)
showThinking?: boolean // 是否展开显示思考过程
}
2. 打字机队列
// 思考过程队列(优先级1)
const thinkingTypewriterQueue = new Map<number, string>()
// 回答内容队列(优先级2)
const typewriterQueue = new Map<number, string>()
// 队列结构:Map<消息ID, 待显示的文本>
// 示例:Map { 1234567890 => "这是待显示的文本..." }
SSE流式数据格式
data: {"data": {"reasoning": "我正在思考", "done": false}}
data: {"data": {"reasoning": "这个问题...", "done": false}}
data: {"data": {"content": "答案是", "done": false}}
data: {"data": {"content": "...", "done": false}}
data: {"data": {"done": true}}
字段说明
reasoning:思考过程的增量文本(每次只返回新增的部分)
content:回答内容的增量文本(每次只返回新增的部分)
done:是否完成(true表示流式传输结束)
关键点
⚠️ 后端返回的是增量文本,不是累积文本!
❌ 错误:每次返回完整内容 "我正在思考这个问题"
✅ 正确:每次返回新增部分 "我正在" → "思考" → "这个问题"
完整流程图
后端SSE流
↓
normalizeChunk (清洗数据)
↓
extractPayloads (提取JSON)
↓
JSON.parse (解析)
↓
判断字段类型
├─→ reasoning → enqueueThinkingTyping → thinkingTypewriterQueue
└─→ content → enqueueTyping → typewriterQueue
↓
typewriterStep (统一处理)
↓
逐字显示到界面
1. 数据清洗 (normalizeChunk)
const normalizeChunk = (raw: string) => {
return raw
.split(/\r?\n/) // 按行分割
.map((line) => {
if (!line.trim()) return ''
// 提取 "data: {...}" 中的 JSON 部分
const dataMatch = line.match(/^\s*data:\s*(.*)$/i)
const content = dataMatch ? dataMatch[1] : line
// 找到第一个 { 的位置,提取完整JSON
const braceIndex = content.indexOf('{')
return braceIndex >= 0 ? content.slice(braceIndex) : ''
})
.join('')
}
// 输入: "data: {"data":{"reasoning":"测试"}}\n"
// 输出: "{"data":{"reasoning":"测试"}}"
2. JSON提取 (extractPayloads)
const extractPayloads = (buffer: string) => {
const payloads: string[] = []
let depth = 0 // 大括号深度
let start = -1 // JSON起始位置
let inString = false
let escapeNext = false
// 逐字符扫描,处理字符串和转义
for (let i = 0; i < buffer.length; i++) {
const char = buffer[i]
// 处理字符串内的引号
if (inString) {
if (escapeNext) {
escapeNext = false
} else if (char === '\\') {
escapeNext = true
} else if (char === '"') {
inString = false
}
continue
}
if (char === '"') {
inString = true
continue
}
// 匹配大括号
if (char === '{') {
if (depth === 0) start = i
depth++
} else if (char === '}') {
depth--
if (depth === 0 && start !== -1) {
// 提取完整的JSON对象
payloads.push(buffer.slice(start, i + 1))
start = -1
}
}
}
// 返回提取的JSON数组和剩余的buffer
const rest = depth > 0 && start !== -1
? buffer.slice(start)
: buffer.slice(restStart)
return { payloads, rest }
}
// 输入: '{"a":1}{"b":2}{"c"'
// 输出: { payloads: ['{"a":1}', '{"b":2}'], rest: '{"c"' }
3. 数据入队
// 处理思考过程
if (payload?.data?.reasoning) {
const reasoningText = payload.data.reasoning
if (reasoningText) {
enqueueThinkingTyping(botMessage, reasoningText)
}
}
// 处理回答内容
const text = payload?.data?.content ?? ''
if (text) {
enqueueTyping(botMessage, text)
}
// 入队函数实现
const enqueueThinkingTyping = (message: ChatMessage, text: string) => {
if (!text) return
// 追加到该消息的思考队列
const existing = thinkingTypewriterQueue.get(message.id) || ''
const newText = existing + text
thinkingTypewriterQueue.set(message.id, newText)
// 启动打字机
if (!typewriter.timer) {
typewriterStep()
}
}
// 示例:
// 第一次:thinkingTypewriterQueue.set(123, "我正在")
// 第二次:thinkingTypewriterQueue.set(123, "我正在思考")
// 第三次:thinkingTypewriterQueue.set(123, "我正在思考这个问题")
核心函数 typewriterStep()
const typewriterStep = () => {
// 1. 检查队列是否为空
if (typewriterQueue.size === 0 && thinkingTypewriterQueue.size === 0) {
typewriter.timer = 0
return
}
let hasMore = false
// 2. 优先处理思考过程队列
if (thinkingTypewriterQueue.size > 0) {
for (const [messageId, pendingText] of thinkingTypewriterQueue.entries()) {
if (pendingText.length > 0) {
const message = messages.value.find(m => m.id === messageId)
if (message) {
// 每次显示1-2个字符
const chunkSize = Math.min(2, Math.ceil(pendingText.length / 20) || 1)
const chunk = pendingText.slice(0, chunkSize)
// 追加到消息的thinking字段
message.thinking = (message.thinking || '') + chunk
// 计算剩余文本
const remaining = pendingText.slice(chunkSize)
if (remaining.length > 0) {
// 还有文本,更新队列
thinkingTypewriterQueue.set(messageId, remaining)
hasMore = true
} else {
// 已完成,删除队列项
thinkingTypewriterQueue.delete(messageId)
}
scrollToBottom()
break // 每次只处理一个消息
}
}
}
}
// 3. 只有思考队列为空时,才处理回答队列
else if (typewriterQueue.size > 0) {
// 同样的逻辑处理content字段
for (const [messageId, pendingText] of typewriterQueue.entries()) {
// ... 类似的处理逻辑
message.content += chunk
// ...
}
}
// 4. 如果还有待处理文本,50ms后继续
if (hasMore || typewriterQueue.size > 0 || thinkingTypewriterQueue.size > 0) {
typewriter.timer = window.setTimeout(typewriterStep, 50)
} else {
typewriter.timer = 0
}
}
执行顺序
1. 检查 thinkingTypewriterQueue 是否有内容
↓ 有
2. 处理思考队列(每次2个字符)
↓ 队列清空
3. 检查 typewriterQueue 是否有内容
↓ 有
4. 处理回答队列(每次2个字符)
↓ 队列清空
5. 结束
// 每次显示的字符数
const chunkSize = Math.min(2, Math.ceil(pendingText.length / 20) || 1)
// 示例:
// 文本长度 100字符 → chunkSize = min(2, 5) = 2
// 文本长度 10字符 → chunkSize = min(2, 1) = 1
// 时间间隔:50ms
typewriter.timer = window.setTimeout(typewriterStep, 50)
// 计算:
// 2字符 / 50ms = 40字符/秒 ≈ 自然的打字速度
场景:用户问"什么是Vue?"
1. 后端返回的SSE流
data: {"data":{"reasoning":"我需要","done":false}}
data: {"data":{"reasoning":"解释Vue","done":false}}
data: {"data":{"content":"Vue是","done":false}}
data: {"data":{"content":"一个渐进式","done":false}}
data: {"data":{"content":"JavaScript框架","done":false}}
data: {"data":{"done":true}}
2. 前端处理流程
第1个数据包:
payload = {data: {reasoning: "我需要", done: false}}
↓
enqueueThinkingTyping(botMessage, "我需要")
↓
thinkingTypewriterQueue.set(123, "我需要")
↓
typewriterStep() 启动
打字机执行(50ms间隔):
t=0ms: message.thinking = "" + "我需" = "我需"
thinkingTypewriterQueue.set(123, "要")
t=50ms: message.thinking = "我需" + "要" = "我需要"
thinkingTypewriterQueue.delete(123) ✓ 思考队列清空
第2个数据包:
payload = {data: {reasoning: "解释Vue", done: false}}
↓
thinkingTypewriterQueue.set(123, "解释Vue")
打字机继续执行:
t=100ms: message.thinking = "我需要" + "解释" = "我需要解释"
t=150ms: message.thinking = "我需要解释" + "Vu" = "我需要解释Vu"
t=200ms: message.thinking = "我需要解释Vu" + "e" = "我需要解释Vue"
thinkingTypewriterQueue清空 ✓
第3个数据包(开始回答):
payload = {data: {content: "Vue是", done: false}}
↓
typewriterQueue.set(123, "Vue是")
↓
因为thinkingTypewriterQueue已空,开始处理typewriterQueue
打字机执行回答:
t=250ms: message.content = "" + "Vu" = "Vu"
t=300ms: message.content = "Vu" + "e是" = "Vue是"
typewriterQueue清空
1. 为什么用两个队列?
独立控制:思考和回答可以独立管理
顺序保证:思考完成后才显示回答
性能优化:避免相互阻塞
2. 为什么是增量文本?
减少网络传输:只传输新增内容
实时性更好:不需要等待完整文本
打字机效果更自然:文本逐步生成
3. 如何保证顺序?
if (thinkingTypewriterQueue.size > 0) {
// 处理思考
} else if (typewriterQueue.size > 0) {
// 只有思考队列为空时才处理回答
}
4. 如何避免重复?
使用 Map 存储,messageId 作为key
每次更新都是追加,不会重复处理
5. 停止打字机
const flushTypewriter = () => {
// 立即显示所有待显示文本
for (const [messageId, pendingText] of thinkingTypewriterQueue.entries()) {
const message = messages.value.find(m => m.id === messageId)
if (message) {
message.thinking = (message.thinking || '') + pendingText
}
}
thinkingTypewriterQueue.clear()
typewriterQueue.clear()
stopTypewriter()
}
// 使用场景:
// - 用户关闭窗口
// - 开始新对话
// - 请求被中断
暂无评论,快来发表第一条评论吧~