// File: components/ai/RagChatWidget.tsx // Change Log // - 2026-05-14: เพิ่ม RAG Chat Widget พร้อม BullMQ polling UI ตาม ADR-023 Phase 4 (T023). 'use client'; import { useState, useRef, useEffect } from 'react'; import { Send, X, Loader2, AlertTriangle, BookOpen } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Card, CardContent, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { AiRagCitation, AiRagJobResult, useCancelRagJob, useRagJobStatus, useSubmitRagQuery, } from '@/lib/api/ai'; interface RagChatWidgetProps { /** publicId ของโครงการสำหรับ project-scoped vector search (FR-002) */ projectPublicId: string; /** แสดง widget ในโหมด disabled เมื่อ AI host offline (FR-006) */ isAiOffline?: boolean; } /** แปลง status เป็น badge variant */ function statusBadge(status: AiRagJobResult['status']): { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline'; } { switch (status) { case 'pending': return { label: 'รอในคิว...', variant: 'outline' }; case 'processing': return { label: 'กำลังประมวลผล...', variant: 'secondary' }; case 'completed': return { label: 'เสร็จสิ้น', variant: 'default' }; case 'failed': return { label: 'ล้มเหลว', variant: 'destructive' }; case 'cancelled': return { label: 'ยกเลิกแล้ว', variant: 'outline' }; default: return { label: status, variant: 'outline' }; } } /** * Widget สำหรับ RAG Conversational Q&A ผ่าน BullMQ polling (ADR-023 FR-009, FR-010, FR-011) * - ส่งคำถามเข้า /api/ai/rag/query → รับ requestPublicId * - Polling /api/ai/rag/jobs/:requestPublicId ทุก 2 วินาที * - แสดง status: pending → processing → completed/failed * - รองรับการยกเลิก job (FR-011) */ export function RagChatWidget({ projectPublicId, isAiOffline = false }: RagChatWidgetProps) { const [question, setQuestion] = useState(''); const [activeRequestId, setActiveRequestId] = useState(null); const [isPolling, setIsPolling] = useState(false); const textareaRef = useRef(null); const submitMutation = useSubmitRagQuery(); const cancelMutation = useCancelRagJob(); const { data: jobResult } = useRagJobStatus(activeRequestId, isPolling); // หยุด polling เมื่อ job เสร็จ/ล้มเหลว/ยกเลิก useEffect(() => { if (!jobResult) return; if ( jobResult.status === 'completed' || jobResult.status === 'failed' || jobResult.status === 'cancelled' ) { setIsPolling(false); if (jobResult.status === 'failed') { toast.error('การค้นหาล้มเหลว กรุณาลองใหม่อีกครั้ง'); } } }, [jobResult]); const handleSubmit = async () => { const trimmed = question.trim(); if (!trimmed || isAiOffline || submitMutation.isPending) return; try { const result = await submitMutation.mutateAsync({ question: trimmed, projectPublicId, }); setActiveRequestId(result.requestPublicId); setIsPolling(true); } catch { toast.error('ไม่สามารถส่งคำถามได้ กรุณาลองใหม่'); } }; const handleCancel = async () => { if (!activeRequestId) return; try { await cancelMutation.mutateAsync(activeRequestId); setIsPolling(false); } catch { toast.error('ไม่สามารถยกเลิกได้'); } }; const handleReset = () => { setQuestion(''); setActiveRequestId(null); setIsPolling(false); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); void handleSubmit(); } }; const isActive = submitMutation.isPending || isPolling; const showResult = !!jobResult && (jobResult.status === 'completed' || jobResult.status === 'failed'); return ( RAG Q&A — ค้นหาจากเอกสารโครงการ {isAiOffline && ( AI Offline )} {isAiOffline && ( ระบบ AI ไม่พร้อมใช้งานในขณะนี้ ฟีเจอร์ RAG ถูกปิดใช้งานชั่วคราว (FR-006) )} {/* Input Area */}