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
}

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐