前端fetch接收text/event-stream流式数据
·
1.接口处理
// src/hooks/fetchStreamClient.ts
export interface StreamChunk {
type: 'chunk' | 'complete' | 'error'
content?: string
sessionId?: string
isComplete?: boolean
timestamp?: string,
tool?: any,
event: string
}
export class FetchStreamClient {
private controller: AbortController | null = null
constructor(private baseURL: string = 'http://localhost:3001') {}
// 使用 Fetch API 发送消息并接收流式响应
async sendMessage(
message: string,
session_id: string,
callbacks: {
onChunk: (data: StreamChunk, type: string) => void
onComplete: (data: StreamChunk) => void
onError: (error: Error) => void
}
): Promise<void> {
const { onChunk, onComplete, onError } = callbacks
// 创建中止控制器
this.controller = new AbortController()
try {
console.log('🚀 发送 Fetch 流式请求,消息:', message.substring(0, 50) + '...')
const response = await fetch(`/api/ai/runs`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
},
body: JSON.stringify({
message,
session_id
}),
signal: this.controller.signal
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
if (!response.body) {
throw new Error('Response body is null')
}
console.log('✅ Fetch 流式连接已建立')
// 处理 ReadableStream
await this.processReadableStream(response.body, onChunk, onComplete)
} catch (error: any) {
if (error.name === 'AbortError') {
console.log('⏹️ 请求被用户取消')
return
}
console.error('❌ Fetch 流式请求失败:', error)
onError(error)
} finally {
this.controller = null
}
}
// 处理 ReadableStream
private async processReadableStream(
body: ReadableStream<Uint8Array>,
onChunk: (data: StreamChunk, type: string) => void,
onComplete: (data?: StreamChunk) => void
): Promise<void> {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
console.log('✅ 流读取完成')
break
}
// 解码数据
const chunk = decoder.decode(value, { stream: true })
buffer += chunk
// 处理 SSE 格式的数据 (data: {...}\n\n)
const lines = buffer.split('\n')
// 清空缓冲区,我们会在每次迭代中重建
buffer = ''
for (const line of lines) {
const trimmedLine = line.trim()
if (trimmedLine.startsWith('data:')) {
const dataStr = trimmedLine.slice(5)
try {
const data: StreamChunk = JSON.parse(dataStr)
console.log('📨 解析到 SSE 数据:', data)
// 处理返回数据,不同ai返回数据格式不一样
if (data.event === 'RunStarted') {
onChunk(data, 'state')
}
// 检查是否完成
if (data.event === 'RunCompleted' || data.isComplete) {
console.log('🎯 收到完成信号')
onComplete(data)
return
}
// 处理数据块
if (data.event === 'RunContent' && data.content) {
onChunk(data, 'content')
}
} catch (error) {
console.error('❌ 解析 SSE 数据失败:', error, '原始数据:', dataStr)
// 如果解析失败,保留这一行到缓冲区
buffer += line + '\n'
}
} else if (trimmedLine) {
// 如果不是 data: 开头的行,保留到缓冲区
buffer += line + '\n'
}
}
}
// 如果循环结束但没有收到完成信号,也调用完成
onComplete()
} catch (error) {
console.error('❌ 处理流时发生错误:', error)
throw error
} finally {
reader.releaseLock()
}
}
// 中止请求
abort(): void {
if (this.controller) {
this.controller.abort()
console.log('⏹️ 请求已中止')
}
}
// 检查是否有活跃请求
isRequesting(): boolean {
return this.controller !== null
}
}
2.使用
// src/components/ChatInput.tsx
import { useState, useRef, forwardRef, useImperativeHandle} from 'react'
import { Button } from '@/components/ui/button'
import { Send, ArrowUp } from 'lucide-react'
import { useChatStore } from '@/stores/chatStore'
import { cn } from '@/lib/utils'
import { FetchStreamClient } from '@/hooks/fetchStreamClient'
export interface ChatInputHandle {
handleSubmit: (customMessage?: string) => Promise<void>
}
const ChatInput = forwardRef<ChatInputHandle>((props, ref) => {
const [input, setInput] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const {
currentSession,
addMessage,
appendToMessage,
} = useChatStore()
const handleSubmit = async (customMessage?: string) => {
const messageToSend = customMessage || input.trim()
// e.preventDefault()
if (!messageToSend) return
setInput('')
addMessage(messageToSend, 'user')
const assistantMessageId = addMessage('', 'assistant')
const client = new FetchStreamClient(`/ai/runs`)
try {
await client.sendMessage(
messageToSend,
currentSession.sessionId,
{
onChunk: (data, type) => {
console.log('收到数据块:', data) // 调试日志
// 使用 appendToMessage 追加内容
appendToMessage(assistantMessageId, data, type)
},
onComplete: (data?) => {
console.log('流式输出完成', data) // 调试日志
appendToMessage(assistantMessageId, data, 'state')
},
onError: (error) => {
console.error('流式输出错误:', error)
appendToMessage(assistantMessageId, {content: '抱歉,回答生成时出现了问题。'}, 'content')
}
},
{
mode: 'word',
speed: 'normal'
}
)
} catch (error) {
console.error('发送消息错误:', error)
}
}
// 暴露handleSubmit给父组件
useImperativeHandle(ref, () => ({
handleSubmit
}))
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}
const handleFormSubmit = (e: React.FormEvent) => {
e.preventDefault()
handleSubmit()
}
return (
<form onSubmit={handleFormSubmit} className="max-w-4xl mx-auto">
<div className="relative">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="有什么我能帮您的吗?"
className="w-full px-4 py-3 pr-12 border border-gray-300 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
rows={2}
style={{
minHeight: '50px',
maxHeight: '200px',
}}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement
target.style.height = 'auto'
target.style.height = Math.min(target.scrollHeight, 120) + 'px'
}}
/>
<div className="absolute right-2 bottom-2 flex space-x-1">
<Button
type="button"
size="sm"
onClick={handleFormSubmit}
className={cn(
"h-8 w-8 p-0",
!input.trim() && 'bg-gray-300'
)}
disabled={!input.trim()}
>
<ArrowUp className="w-6 h-6" color="white"/>
</Button>
</div>
</div>
</form>
)
})
ChatInput.displayName = 'ChatInput'
export default ChatInput
// src/stores/chatStore.ts
import { create } from 'zustand'
import type { ChatSession, Message } from '@/types/chat'
import { fetchSessionDetail } from '@/http/api/chat'
import {v4 as uuidv4} from "uuid"
interface ChatState {
sessions: ChatSession[]
currentSession: ChatSession | null
loading: boolean
// Actions
createNewSession: () => void
addMessage: (content: string, role: 'user' | 'assistant') => void
setLoading: (loading: boolean) => void
clearCurrentSession: () => void
appendToMessage: (messageId: string, content: string) => void
sendMessageWithSession: (content: string) => Promise<void>
}
export const useChatStore = create<ChatState>((set, get) => ({
sessions: [],
currentSession: null,
isThinking: false,
isStreaming: false,
streamingMessageId: null,
loading: false,
createNewSession: () => {
const newSession: ChatSession = {
id: uuidv4(),
sessionId: '', // 初始为空,等待服务端返回
title: '新对话',
messages: [],
createdAt: new Date(),
updatedAt: new Date(),
messageCount: 0
}
set({
currentSession: newSession,
sessions: [newSession, ...get().sessions]
})
},
addMessage: (content: string, role: 'user' | 'assistant') => {
const { currentSession, sessions } = get()
if (!currentSession) return ''
const messageId = uuidv4()
const newMessage: Message = {
id: messageId,
content,
role,
timestamp: new Date(),
stateList: [],
toolList: []
}
const updatedSession = {
...currentSession,
messages: [...currentSession.messages, newMessage],
updatedAt: new Date(),
messageCount: currentSession.messages.length + 1
}
// 如果是第一条用户消息,自动生成标题
if (role === 'user' && currentSession.messages.length === 0) {
updatedSession.title = content.slice(0, 20) + (content.length > 20 ? '...' : '')
}
set({
currentSession: updatedSession,
sessions: sessions.map(session =>
session.id === currentSession.id ? updatedSession : session
)
})
return messageId
},
getCurrentSession: async (session_id) => {
get().setLoading(true)
try {
const res = await fetchSessionDetail(session_id)
const data = res.data || {}
const updatedSession = {
id: session_id,
session_id,
messages: (data.chat_history || []).map(item => {
return {
...item,
id: uuidv4(),
stateList: []
}
})
}
set({
currentSession: updatedSession
})
} catch (e) {
} finally {
get().setLoading(false)
}
},
setLoading: (loading: boolean) => {
set({ loading })
},
clearCurrentSession: () => {
set({ currentSession: null })
},
// appendToMessage
appendToMessage: (messageId: string, data: object, type: string) => {
const state = get()
const { currentSession } = state
const updatedMessages = currentSession.messages.map(message =>
{
if (message.id === messageId) {
const item = {
...message,
}
if (type === 'state') {
if (item.stateList.includes('RunCompleted') && !data) return
if (!item.stateList.includes('RunCompleted') && !data) {
item.stateList.push('RunCompleted')
return
}
item.stateList.push(data.event)
if (data.event === 'RunStarted') {
item.start = data.created_at || 0
} else if (data.event === 'RunCompleted') {
item.end = data.created_at || 0
item.time = item.end - item.start
}
} else if (type === 'content') {
item.content = item.content + data.content
}
return item
}
return message
}
)
const updatedSession = {
...currentSession,
messages: updatedMessages,
updatedAt: new Date()
}
// 直接设置状态
set({
currentSession: updatedSession,
sessions: state.sessions.map(session =>
session.id === currentSession.id ? updatedSession : session
)
})
}
}))
// src/types/chat.ts
export interface Message {
id: string
content: string
role: 'user' | 'assistant'
timestamp: Date
stateList: string[]
}
export interface ChatSession {
id: string
title: string
messages: Message[]
messageCount: number
sessionId: string
}
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)