บทที่ 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 มีโครงสร้างดังนี้:
- User Input: ผู้ใช้พิมพ์ข้อความ -> กดส่ง
- App State: เพิ่มข้อความผู้ใช้ลงใน List -> อัปเดตหน้าจอทันที
- API Call: ส่งข้อความ + ประวัติการคุยเก่าๆ ไปหา Backend
- AI Processing: Backend ส่งต่อให้ Gemini/OpenAI
- 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 ส่วน:
- ListView: แสดงรายการข้อความ
- Typing Indicator: แสดงตอน AI กำลังคิด
- 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
สิ่งที่ต้องทำ:
- หุ้ม
_ChatBubbleด้วยGestureDetector - เมื่อ
onLongPress: แสดงเมนู (Copy) - เมื่อกด Copy: บันทึกข้อความลง Clipboard
โจทย์ที่ 2: Markdown Support
สิ่งที่ต้องทำ:
- ติดตั้ง package
flutter_markdown - เปลี่ยน
Textwidget เป็นMarkdownBody - ทดสอบให้ AI เขียนโค้ด หรือทำตัวหนา/เอียง
โจทย์ที่ 3: Quick Replies (Action Chips)
สิ่งที่ต้องทำ:
- เพิ่มแถบ
CreateChoiceChipเหนือช่องพิมพ์ - มีคำสั่งลัดเช่น "สรุปให้หน่อย", "แปลเป็นอังกฤษ", "ขอคำคม"
- เมื่อกด -> ส่งข้อความนั้นทันที
📌 สรุป
การทำ Chat App ไม่ยาก แต่ "Details" สำคัญมาก:
- Instant Feedback: กดส่งปุ๊บ ต้องขึ้นปั๊บ
- Loading State: ต้องบอกว่า "กำลังคิด"
- Auto Scroll: ต้องเลื่อนลงมาที่ข้อความใหม่เสมอ
ไฟล์ที่เกี่ยวข้อง
🔜 บทถัดไป
บทที่ 19: Admin Dashboard ในฐานะเจ้าของแอป เราต้องมีหน้าบ้านไว้ดูสถิติและจัดการผู้ใช้!