บทนำ: เมื่อ Timeout ทำให้ UX พังทลาย

เช้าวันศุกร์ที่ผมนั่งทำโปรเจกต์ Chatbot สำหรับลูกค้าองค์กรใหญ่แห่งหนึ่ง ทุกอย่างดูราบรื่นจนกระทั่งทีม QA ส่ง Bug Report มาว่า "ผู้ใช้บ่นว่าข้อความตอบกลับจาก AI ใช้เวลานานเกินไป บางครั้งรอแล้วไม่มาสักที" พอเปิด Console ดูเจอ Error ที่ชัดเจน: ConnectionError: timeout of 30000ms exceeded และ Error: Network connection lost หลังจากนั่งแก้ทั้งวัน ผมค้นพบว่าปัญหาคือการใช้ Polling แทน Streaming ทำให้ User Experience แย่มาก บทความนี้จะสอนวิธีใช้ Server-Sent Events (SSE) เพื่อส่งข้อความจาก AI แบบ Real-time Streaming ที่ทำให้ผู้ใช้เห็นตัวอักษรปรากฏทีละตัว เหมือนกับ ChatGPT ที่เราใช้กันทุกวัน พร้อมโค้ดตัวอย่างที่พร้อมใช้งานจริงสำหรับทั้ง Vue 3 และ React

Server-Sent Events คืออะไร และทำไมต้องใช้กับ AI

Server-Sent Events หรือ SSE เป็นเทคโนโลยีที่ทำให้ Server สามารถส่งข้อมูลไปยัง Browser ได้อย่างต่อเนื่องผ่าน HTTP Connection เดียว โดยไม่ต้องให้ Client ส่ง Request ซ้ำๆ ต่างจาก REST API ทั่วไปที่ต้องรอ Response แล้วค่อยส่ง Request ใหม่ ซึ่งเหมาะมากกับการใช้งาน AI Streaming เพราะ AI Model ต้องประมวลผลทีละ Token และส่งออกมาทีละส่วน ข้อดีหลักของ SSE คือ: - Low Latency — ผู้ใช้เห็นผลลัพธ์ทันทีที่ AI ประมวลผลเสร็จ - Persistent Connection — เปิด Connection ครั้งเดียวส่งข้อมูลได้ต่อเนื่อง - Auto-reconnect — Browser มี built-in รองรับการเชื่อมต่อใหม่อัตโนมัติ - Simple Implementation — ใช้ EventSource API ง่ายกว่า WebSocket สำหรับโปรเจกต์ AI Chatbot ที่ต้องการประสบการณ์เหมือน ChatGPT หรือ Claude การใช้ SSE เป็นทางเลือกที่ดีกว่า WebSocket เพราะโค้ดฝั่ง Server ง่ายกว่าและเพียงพอต่อความต้องการ

การใช้งาน SSE กับ HolySheep AI API

ก่อนเข้าสู่โค้ดตัวอย่าง มาดูว่า HolySheep AI มีข้อดีอย่างไร HolySheep AI เป็น AI API Gateway ที่รวม Model ยอดนิยมไว้ในที่เดียว ให้บริการด้วยโครงสร้างราคาที่ประหยัดมาก — อัตราแลกเปลี่ยน ¥1=$1 ทำให้ค่าใช้จ่ายต่ำกว่า Official API ถึง 85% โดยสามารถ สมัครที่นี่ และรับเครดิตฟรีเมื่อลงทะเบียน รองรับการชำระเงินผ่าน WeChat และ Alipay พร้อม Latency เฉลี่ยต่ำกว่า 50ms ซึ่งเร็วมากสำหรับ Streaming Application ราคาเฉพาะสำหรับปี 2026 มีดังนี้: - GPT-4.1: $8/MTok - Claude Sonnet 4.5: $15/MTok - Gemini 2.5 Flash: $2.50/MTok - DeepSeek V3.2: $0.42/MTok (ประหยัดที่สุดสำหรับงานทั่วไป)

โค้ดตัวอย่าง: Vue 3 Composition API

สำหรับ Vue 3 เราจะสร้าง composable function ที่จัดการ SSE connection ทั้งหมด เพื่อให้สามารถ reuse ได้ง่ายในหลาย Component
// composables/useStreamingAI.ts
import { ref, onUnmounted } from 'vue'

interface StreamMessage {
  content: string
  done: boolean
  error?: string
}

export function useStreamingAI() {
  const messages = ref<string[]>([])
  const isLoading = ref(false)
  const error = ref<string | null>(null)
  let eventSource: EventSource | null = null
  let currentContent = ''

  const startStream = async (userMessage: string) => {
    // ล้างค่าก่อนหน้า
    messages.value = []
    currentContent = ''
    error.value = null
    isLoading.value = true

    try {
      // ส่ง POST request เพื่อเริ่ม stream
      const response = await fetch('https://api.holysheep.ai/v1/chat/completions', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': 'Bearer YOUR_HOLYSHEEP_API_KEY'
        },
        body: JSON.stringify({
          model: 'gpt-4.1',
          messages: [
            { role: 'user', content: userMessage }
          ],
          stream: true  // สำคัญ: เปิด streaming mode
        })
      })

      if (!response.ok) {
        throw new Error(HTTP ${response.status}: ${response.statusText})
      }

      // อ่าน response เป็น ReadableStream
      const reader = response.body?.getReader()
      const decoder = new TextDecoder()

      if (!reader) {
        throw new Error('Response body is null')
      }

      while (true) {
        const { done, value } = await reader.read()
        
        if (done) break

        const chunk = decoder.decode(value, { stream: true })
        
        // Parse SSE data ที่มี format: data: {...}\n\n
        const lines = chunk.split('\n')
        
        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const data = line.slice(6)
            
            // ตรวจสอบว่าเป็น [DONE] message หรือไม่
            if (data === '[DONE]') {
              isLoading.value = false
              return
            }

            try {
              const parsed = JSON.parse(data)
              const content = parsed.choices?.[0]?.delta?.content
              
              if (content) {
                currentContent += content
                messages.value = [currentContent]
              }
            } catch (parseError) {
              // ในกรณีที่ JSON parse ไม่ได้ ให้ข้ามไป
              console.warn('Parse error:', parseError)
            }
          }
        }
      }

      isLoading.value = false

    } catch (err) {
      isLoading.value = false
      error.value = err instanceof Error ? err.message : 'Unknown error occurred'
      console.error('Streaming error:', err)
    }
  }

  const stopStream = () => {
    if (eventSource) {
      eventSource.close()
      eventSource = null
    }
    isLoading.value = false
  }

  // Cleanup เมื่อ component ถูก unmount
  onUnmounted(() => {
    stopStream()
  })

  return {
    messages,
    isLoading,
    error,
    startStream,
    stopStream
  }
}

โค้ดตัวอย่าง: React Hook

สำหรับ React เราจะสร้าง custom hook ที่ใช้งานได้ทั้งกับ React และ Next.js
// hooks/useStreamingChat.ts
import { useState, useCallback, useRef } from 'react'

interface UseStreamingChatOptions {
  apiKey: string
  model?: string
  baseUrl?: string
}

interface Message {
  id: string
  role: 'user' | 'assistant'
  content: string
}

export function useStreamingChat({
  apiKey,
  model = 'gpt-4.1',
  baseUrl = 'https://api.holysheep.ai/v1'
}: UseStreamingChatOptions) {
  const [messages, setMessages] = useState<Message[]>([])
  const [input, setInput] = useState('')
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)
  const abortControllerRef = useRef<AbortController | null>(null)

  const sendMessage = useCallback(async (content: string) => {
    // สร้าง AbortController สำหรับ cancel request
    abortControllerRef.current = new AbortController()
    
    // เพิ่ม user message
    const userMessage: Message = {
      id: user-${Date.now()},
      role: 'user',
      content
    }
    
    setMessages(prev => [...prev, userMessage])
    setInput('')
    setIsLoading(true)
    setError(null)

    // สร้าง placeholder สำหรับ assistant message
    const assistantMessageId = assistant-${Date.now()}
    let fullResponse = ''

    try {
      const response = await fetch(${baseUrl}/chat/completions, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': Bearer ${apiKey}
        },
        body: JSON.stringify({
          model,
          messages: [
            ...messages.map(m => ({ role: m.role, content: m.content })),
            { role: 'user', content }
          ],
          stream: true
        }),
        signal: abortControllerRef.current.signal
      })

      if (!response.ok) {
        const errorData = await response.json().catch(() => ({}))
        throw new Error(errorData.error?.message || Request failed: ${response.status})
      }

      // เพิ่ม empty assistant message
      setMessages(prev => [...prev, {
        id: assistantMessageId,
        role: 'assistant',
        content: ''
      }])

      // อ่าน streaming response
      const reader = response.body?.getReader()
      if (!reader) throw new Error('No response body')

      const decoder = new TextDecoder()

      while (true) {
        const { done, value } = await reader.read()
        
        if (done) {
          setIsLoading(false)
          break
        }

        const chunk = decoder.decode(value, { stream: true })
        
        // Parse SSE format
        const lines = chunk.split('\n')
        
        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const data = line.slice(6)
            
            if (data === '[DONE]') {
              setIsLoading(false)
              break
            }

            try {
              const parsed = JSON.parse(data)
              const delta = parsed.choices?.[0]?.delta?.content
              
              if (delta) {
                fullResponse += delta
                
                // Update assistant message
                setMessages(prev => prev.map(msg => 
                  msg.id === assistantMessageId 
                    ? { ...msg, content: fullResponse }
                    : msg
                ))
              }
            } catch {
              // Skip invalid JSON
            }
          }
        }
      }

    } catch (err) {
      setIsLoading(false)
      
      if (err instanceof Error) {
        if (err.name === 'AbortError') {
          setError('Request was cancelled')
        } else {
          setError(err.message)
        }
      } else {
        setError('An unexpected error occurred')
      }

      // ลบ assistant message ที่ถูกสร้างไว้
      setMessages(prev => prev.filter(m => m.id !== assistantMessageId))
    }
  }, [apiKey, baseUrl, model, messages])

  const stopGeneration = useCallback(() => {
    abortControllerRef.current?.abort()
    setIsLoading(false)
  }, [])

  const clearMessages = useCallback(() => {
    setMessages([])
    setError(null)
  }, [])

  return {
    messages,
    input,
    setInput,
    isLoading,
    error,
    sendMessage,
    stopGeneration,
    clearMessages
  }
}

ตัวอย่างการใช้งานใน Component

Vue Component

<template>
  <div class="chat-container">
    <div class="messages">
      <div 
        v-for="(msg, index) in messages" 
        :key="index"
        class="message"
        :class="msg.role"
      >
        {{ msg.content }}
      </div>
      
      <div v-if="isLoading" class="typing-indicator">
        <span></span><span></span><span></span>
      </div>
    </div>

    <div v-if="error" class="error-message">
      ⚠️ {{ error }}
    </div>

    <div class="input-area">
      <input 
        v-model="userInput"
        @keyup.enter="handleSend"
        placeholder="พิมพ์ข้อความของคุณ..."
        :disabled="isLoading"
      />
      <button 
        @click="handleSend" 
        :disabled="isLoading || !userInput.trim()"
      >
        {{ isLoading ? 'กำลังส่ง...' : 'ส่ง' }}
      </button>
      <button 
        v-if="isLoading"
        @click="stopStream"
        class="stop-btn"
      >
        หยุด
      </button>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { useStreamingAI } from './composables/useStreamingAI'

const userInput = ref('')
const { messages, isLoading, error, startStream, stopStream } = useStreamingAI()

// แปลง messages เป็น format ที่เหมาะกับ UI
const displayMessages = computed(() => 
  messages.value.map((content, index) => ({
    id: index,
    role: index % 2 === 0 ? 'user' : 'assistant',
    content
  }))
)

const handleSend = async () => {
  if (!userInput.value.trim() || isLoading.value) return
  await startStream(userInput.value)
  userInput.value = ''
}
</script>

React Component

// ChatComponent.jsx
import React from 'react'
import { useStreamingChat } from './hooks/useStreamingChat'

const API_KEY = 'YOUR_HOLYSHEEP_API_KEY'

export default function ChatComponent() {
  const {
    messages,
    input,
    setInput,
    isLoading,
    error,
    sendMessage,
    stopGeneration,
    clearMessages
  } = useStreamingChat({
    apiKey: API_KEY,
    model: 'gpt-4.1'
  })

  const handleSubmit = (e) => {
    e.preventDefault()
    if (input.trim() && !isLoading) {
      sendMessage(input)
    }
  }

  return (
    <div className="chat-container">
      <div className="messages-area">
        {messages.map((msg) => (
          <div 
            key={msg.id} 
            className={message message-${msg.role}}
          >
            <div className="message-content"&