Files
lcbp3/frontend/components/ai/RagChatWidget.tsx
T
admin 6cb3ae10ee
CI / CD Pipeline / build (push) Failing after 5m36s
CI / CD Pipeline / deploy (push) Has been skipped
feat(ai): unify AI architecture, implement RAG and legacy migration
2026-05-15 11:10:44 +07:00

294 lines
10 KiB
TypeScript

// 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<string | null>(null);
const [isPolling, setIsPolling] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
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 (
<Card className="w-full">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<BookOpen className="h-4 w-4" />
RAG Q&amp;A
{isAiOffline && (
<Badge variant="destructive" className="ml-auto text-xs">
AI Offline
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{isAiOffline && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
AI RAG (FR-006)
</AlertDescription>
</Alert>
)}
{/* Input Area */}
<div className="space-y-2">
<Textarea
ref={textareaRef}
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="พิมพ์คำถามเกี่ยวกับเอกสารโครงการ... (Ctrl+Enter เพื่อส่ง)"
className="min-h-[80px] resize-none"
maxLength={500}
disabled={isAiOffline || isActive}
/>
<p className="text-right text-xs text-muted-foreground">
{question.length}/500
</p>
</div>
{/* Job Status Indicator */}
{activeRequestId && jobResult && (
<div className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{(jobResult.status === 'pending' || jobResult.status === 'processing') && (
<Loader2 className="h-3 w-3 animate-spin" />
)}
<Badge variant={statusBadge(jobResult.status).variant}>
{statusBadge(jobResult.status).label}
</Badge>
</div>
{jobResult.status !== 'completed' && jobResult.status !== 'failed' && (
<Button
variant="ghost"
size="sm"
onClick={() => void handleCancel()}
disabled={cancelMutation.isPending}
className="h-6 px-2 text-xs"
>
<X className="h-3 w-3 mr-1" />
</Button>
)}
</div>
{/* Answer */}
{showResult && jobResult.answer && (
<div className="space-y-2">
<p className="text-sm font-medium">:</p>
<p className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
{jobResult.answer}
</p>
{typeof jobResult.confidence === 'number' && (
<p className="text-xs text-muted-foreground">
: {(jobResult.confidence * 100).toFixed(1)}%
{jobResult.usedFallbackModel && ' (Fallback Model)'}
</p>
)}
</div>
)}
{/* Error */}
{showResult && jobResult.status === 'failed' && (
<p className="text-sm text-destructive">{jobResult.errorMessage ?? 'เกิดข้อผิดพลาดที่ไม่ทราบสาเหตุ'}</p>
)}
{/* Citations */}
{showResult && jobResult.citations && jobResult.citations.length > 0 && (
<CitationList citations={jobResult.citations} />
)}
</div>
)}
</CardContent>
<CardFooter className="flex gap-2 pt-0">
{!showResult ? (
<Button
onClick={() => void handleSubmit()}
disabled={!question.trim() || isAiOffline || isActive}
size="sm"
className="ml-auto"
>
{isActive ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Send className="h-4 w-4 mr-2" />
)}
{isActive ? 'กำลังค้นหา...' : 'ส่งคำถาม'}
</Button>
) : (
<Button variant="outline" size="sm" onClick={handleReset} className="ml-auto">
</Button>
)}
</CardFooter>
</Card>
);
}
/** แสดง citations จาก RAG results */
function CitationList({ citations }: { citations: AiRagCitation[] }) {
const [expanded, setExpanded] = useState(false);
const visible = expanded ? citations : citations.slice(0, 3);
return (
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">
({citations.length} ):
</p>
{visible.map((c, index) => (
<div
key={`${String(c.pointId)}-${index}`}
className="rounded border border-border bg-muted/40 p-2 text-xs space-y-0.5"
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">
{c.docType ?? 'เอกสาร'}
{c.docNumber ? `${c.docNumber}` : ''}
</span>
<span className="text-muted-foreground shrink-0">
{(c.score * 100).toFixed(1)}%
</span>
</div>
{c.snippet && (
<p className="text-muted-foreground line-clamp-2">{c.snippet}</p>
)}
</div>
))}
{citations.length > 3 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-1 text-xs w-full"
onClick={() => setExpanded(!expanded)}
>
{expanded ? 'แสดงน้อยลง' : `ดูอีก ${citations.length - 3} รายการ`}
</Button>
)}
</div>
);
}