บทที่ 18advanced|14 KB

บทที่ 18: Real-time Chat Feature (AI Integration)

บทที่ 18: Real-time Chat Feature (AI Integration)

🎯 เป้าหมายการเรียนรู้

หลังจากเรียนบทนี้แล้ว คุณจะสามารถ:

  • สร้างหน้าจอแชทที่มี Interface คล้ายแอพแชทชื่อดัง (Messenger/Line)
  • เชื่อมต่อกับ AI Backend เพื่อคุยตอบโต้แบบ Real-time
  • จัดการ State ของข้อความ (User Message vs AI Message)
  • สร้างระบบ Auto-scroll เมื่อมีข้อความใหม่
  • สร้าง Typing Indicator Animation (จุดสามจุดกระพริบๆ)

📚 ทฤษฎีและแนวคิด

1. Chat Architecture

การทำระบบแชทกับ AI มีโครงสร้างดังนี้:

  1. User Input: ผู้ใช้พิมพ์ข้อความ -> กดส่ง
  2. App State: เพิ่มข้อความผู้ใช้ลงใน List -> อัปเดตหน้าจอทันที
  3. API Call: ส่งข้อความ + ประวัติการคุยเก่าๆ ไปหา Backend
  4. AI Processing: Backend ส่งต่อให้ Gemini/OpenAI
  5. Response: ได้รับคำตอบ -> เพิ่มลงใน List -> อัปเดตหน้าจอ

2. Context History

AI ไม่มีความจำ (Stateless) ถ้าเราอยากให้มันคุยรู้เรื่องต่อเนื่อง เราต้องส่ง "ประวัติการคุย" (Context) กลับไปให้มันทุกครั้ง

  • User: "หวัดดี เราชื่อเอ"
  • AI: "สวัสดีครับคุณเอ"
  • User: "เราชื่ออะไร" -> ต้องส่งประวัติ ["หวัดดี เราชื่อเอ", "สวัสดีครับคุณเอ"] ไปด้วย AI ถึงจะตอบได้

💻 การเขียนโค้ด (Implementation)

1. สร้าง Data Model

ไฟล์: mobile/lib/models/chat_message.dart

เราต้องแยกให้ออกว่าข้อความไหนของใคร เพื่อแสดงผลชิดซ้าย/ขวา

enum MessageSender { user, ai }

class ChatMessage {
  final String text;
  final MessageSender sender;
  final DateTime timestamp;

  ChatMessage({
    required this.text,
    required this.sender, // ใครเป็นคนส่ง
    DateTime? timestamp,
  }) : this.timestamp = timestamp ?? DateTime.now();
  
  // แปลงจาก JSON (ถ้าเก็บประวัติใน DB)
  factory ChatMessage.fromJson(Map<String, dynamic> json) {
    return ChatMessage(
      text: json['text'],
      sender: json['sender'] == 'user' ? MessageSender.user : MessageSender.ai,
    );
  }
}

2. สร้าง Chat Screen

ไฟล์: mobile/lib/screens/chat_screen.dart

หน้าจอหลักประกอบด้วย 3 ส่วน:

  1. ListView: แสดงรายการข้อความ
  2. Typing Indicator: แสดงตอน AI กำลังคิด
  3. Input Field: ช่องพิมพ์ด้านล่าง
import 'package:flutter/material.dart';
import '../models/chat_message.dart';
import '../services/chat_api.dart'; // เดี๋ยวสร้าง

**อธิบายทีละบรรทัด (Imports):**

- `import 'package:flutter/material.dart'`
  - UI Framework หลัก

- `import '../models/chat_message.dart'`
  - นำเข้า Data Model ที่เราสร้างไว้เพื่อเก็บข้อมูลข้อความ

**อธิบาย:**

class ChatScreen extends StatefulWidget {
  const ChatScreen({super.key});

  @override
  State<ChatScreen> createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
  // 1. เก็บรายการข้อความ
  final List<ChatMessage> _messages = [];
  
  // 2. Controller สำหรับช่องพิมพ์
  final TextEditingController _inputController = TextEditingController();
  
  // 3. Controller สำหรับเลื่อนหน้าจอ
  final ScrollController _scrollController = ScrollController();
  
  // 4. สถานะกำลังพิมพ์
  bool _isTyping = false;

  // ฟังก์ชันส่งข้อความ
  Future<void> _sendMessage() async {
    final text = _inputController.text.trim();
    if (text.isEmpty) return;

    // 1. เคลียร์ช่องพิมพ์
    _inputController.clear();

    // 2. เพิ่มข้อความ User ลงหน้าจอทันที (Optimistic UI)
    setState(() {
      _messages.add(ChatMessage(
        text: text, 
        sender: MessageSender.user
      ));
      _isTyping = true; // เริ่มโชว์ typing indicator
    });
    _scrollToBottom();

    try {
      // 3. เรียก API
      // (ส่งประวัติ 10 ข้อความล่าสุดไปด้วย เพื่อประหยัด Token)
      final history = _messages.length > 10 
          ? _messages.sublist(_messages.length - 10) 
          : _messages;
          
      final responseText = await ChatApi.sendMessage(text, history);

      // 4. เพิ่มคำตอบ AI ลงหน้าจอ
      if (mounted) {
        setState(() {
          _messages.add(ChatMessage(
            text: responseText, 
            sender: MessageSender.ai
          ));
          _isTyping = false; // หยุดโชว์ typing indicator
        });
        _scrollToBottom();
      }
    } catch (e) {
      if (mounted) {
        setState(() {
          _isTyping = false;
          _messages.add(ChatMessage(
            text: "ขออภัย เกิดข้อผิดพลาด: $e", 
            sender: MessageSender.ai
          ));
        });
      }
    }
  }

  // ฟังก์ชันเลื่อนหน้าจอลงล่างสุด
  void _scrollToBottom() {
    // รอให้ List Render เสร็จก่อนนิดนึง
    Future.delayed(const Duration(milliseconds: 100), () {
      if (_scrollController.hasClients) {
        _scrollController.animateTo(
          _scrollController.position.maxScrollExtent,
          duration: const Duration(milliseconds: 300),
          curve: Curves.easeOut,
        );
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("AI Chat")),
      body: Column(
        children: [
          // ส่วนแสดงข้อความ
          Expanded(
            child: ListView.builder(
              controller: _scrollController,
              padding: const EdgeInsets.all(16),
              itemCount: _messages.length + (_isTyping ? 1 : 0),
              itemBuilder: (context, index) {
                // ถ้าเป็น item สุดท้ายและกำลังพิมพ์ -> show indicator
                if (_isTyping && index == _messages.length) {
                  return const _TypingIndicator();
                }
                
                final msg = _messages[index];
                return _ChatBubble(message: msg);
              },
            ),
          ),
          
          // ส่วนช่องพิมพ์
          Container(
            padding: const EdgeInsets.all(8),
            decoration: BoxDecoration(
              color: Colors.white,
              boxShadow: [BoxShadow(blurRadius: 5, color: Colors.black12)],
            ),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _inputController,
                    decoration: InputDecoration(
                      hintText: "พิมพ์ข้อความ...",
                      border: OutlineInputBorder(
                        borderRadius: BorderRadius.circular(24),
                      ),
                      contentPadding: EdgeInsets.symmetric(horizontal: 16),
                    ),
                    onSubmitted: (_) => _sendMessage(), // กด Enter ส่งได้เลย
                  ),
                ),
                SizedBox(width: 8),
                IconButton(
                  icon: Icon(Icons.send, color: Colors.blue),
                  onPressed: _sendMessage,
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

3. สร้าง UI Components ย่อย

แยก Widget เล็กๆ ออกมาเพื่อให้ Code อ่านง่าย

Chat Bubble

class _ChatBubble extends StatelessWidget {
  final ChatMessage message;

  const _ChatBubble({required this.message});

  @override
  Widget build(BuildContext context) {
    final isUser = message.sender == MessageSender.user;
    
    return Align(
      // User ชิดขวา, AI ชิดซ้าย
      alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
      child: Container(
        margin: const EdgeInsets.symmetric(vertical: 4),
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
        decoration: BoxDecoration(
          // User สีน้ำเงิน, AI สีเทา
          color: isUser ? Colors.blue : Colors.grey[200],
          borderRadius: BorderRadius.only(
            topLeft: Radius.circular(16),
            topRight: Radius.circular(16),
            // ทำหาง bubble
            bottomLeft: Radius.circular(isUser ? 16 : 4),
            bottomRight: Radius.circular(isUser ? 4 : 16),
          ),
        ),
        // จำกัดความกว้างไม่เกิน 75% ของจอ
        constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75),
        child: Text(
          message.text,
          style: TextStyle(
            color: isUser ? Colors.white : Colors.black,
            fontSize: 16,
          ),
        ),
      ),
    );
  }
}

Typing Indicator (Animation)

class _TypingIndicator extends StatelessWidget {
  const _TypingIndicator();

  @override
  Widget build(BuildContext context) {
    return Align(
      alignment: Alignment.centerLeft,
      child: Container(
        padding: const EdgeInsets.all(12),
        margin: const EdgeInsets.symmetric(vertical: 4),
        decoration: BoxDecoration(
          color: Colors.grey[200],
          borderRadius: BorderRadius.circular(16),
        ),
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            // ใช้ CircleAvatar เล็กๆ 3 อันกระพริบ (Simplest version)
            Text("กำลังพิมพ์...", style: TextStyle(color: Colors.grey)),
          ],
        ),
      ),
    );
  }
}

4. สร้าง Chat API Service

ไฟล์: mobile/lib/services/chat_api.dart

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'api_config.dart';
import 'session.dart';
import '../models/chat_message.dart';

class ChatApi {
  static Future<String> sendMessage(String text, List<ChatMessage> history) async {
    final token = Session.token;
    if (token == null) throw Exception("Not logged in");

    final uri = Uri.parse("$kApiBase/api/chat");
    
    // แปลง history เป็น format ที่ backend เข้าใจ
    final contextHistory = history.map((m) => {
      "role": m.sender == MessageSender.user ? "user" : "model",
      "content": m.text
    }).toList();

    final response = await http.post(
      uri,
      headers: {
        "Content-Type": "application/json",
        "Authorization": "Bearer $token",
      },
      body: jsonEncode({
        "message": text,
        "history": contextHistory,
      }),
    );

    if (response.statusCode != 200) {
      throw Exception("AI Error: ${response.statusCode}");
    }

    final data = jsonDecode(response.body);
    return data['reply'] ?? "ขออภัย ฉันไม่เข้าใจ";
  }
}

📝 แบบฝึกหัด

โจทย์ที่ 1: Copy Message

สิ่งที่ต้องทำ:

  1. หุ้ม _ChatBubble ด้วย GestureDetector
  2. เมื่อ onLongPress: แสดงเมนู (Copy)
  3. เมื่อกด Copy: บันทึกข้อความลง Clipboard

โจทย์ที่ 2: Markdown Support

สิ่งที่ต้องทำ:

  1. ติดตั้ง package flutter_markdown
  2. เปลี่ยน Text widget เป็น MarkdownBody
  3. ทดสอบให้ AI เขียนโค้ด หรือทำตัวหนา/เอียง

โจทย์ที่ 3: Quick Replies (Action Chips)

สิ่งที่ต้องทำ:

  1. เพิ่มแถบ CreateChoiceChip เหนือช่องพิมพ์
  2. มีคำสั่งลัดเช่น "สรุปให้หน่อย", "แปลเป็นอังกฤษ", "ขอคำคม"
  3. เมื่อกด -> ส่งข้อความนั้นทันที

📌 สรุป

การทำ Chat App ไม่ยาก แต่ "Details" สำคัญมาก:

  • Instant Feedback: กดส่งปุ๊บ ต้องขึ้นปั๊บ
  • Loading State: ต้องบอกว่า "กำลังคิด"
  • Auto Scroll: ต้องเลื่อนลงมาที่ข้อความใหม่เสมอ

ไฟล์ที่เกี่ยวข้อง


🔜 บทถัดไป

บทที่ 19: Admin Dashboard ในฐานะเจ้าของแอป เราต้องมีหน้าบ้านไว้ดูสถิติและจัดการผู้ใช้!