690519:1631 224 to 226 AI #01
CI / CD Pipeline / build (push) Failing after 3m57s
CI / CD Pipeline / deploy (push) Has been skipped

This commit is contained in:
2026-05-19 16:31:50 +07:00
parent 3e25097470
commit ea5499123e
127 changed files with 12387 additions and 42 deletions
@@ -0,0 +1,123 @@
// File: frontend/components/ai/__tests__/ai-chat-panel.test.tsx
// Change Log:
// - 2026-05-19: สร้าง Unit Test สำหรับคอมโพเนนต์ AiChatPanel
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { AiChatPanel } from '../ai-chat-panel';
import { useAiChat } from '@/hooks/use-ai-chat';
vi.mock('@/hooks/use-ai-chat');
describe('AiChatPanel Component', () => {
const mockContext = { type: 'rfa', publicId: '019505a1-7c3e-7000-8000-abc123def456' };
const mockOnClose = vi.fn();
const mockOnToggle = vi.fn();
const mockSendMessage = vi.fn();
const mockClearHistory = vi.fn();
beforeEach(() => {
window.HTMLElement.prototype.scrollIntoView = vi.fn();
vi.clearAllMocks();
vi.mocked(useAiChat).mockReturnValue({
messages: [],
sendMessage: mockSendMessage,
clearHistory: mockClearHistory,
isLoading: false,
isOpen: false,
setIsOpen: vi.fn(),
toggleOpen: vi.fn(),
});
});
it('ควรเรนเดอร์คอมโพเนนต์อย่างถูกต้อง', () => {
render(
<AiChatPanel
context={mockContext}
isOpen={true}
onClose={mockOnClose}
onToggle={mockOnToggle}
/>
);
expect(screen.getByText('ผู้ช่วยอัจฉริยะ AI')).toBeInTheDocument();
expect(screen.getByPlaceholderText(/ถาม AI เกี่ยวกับเอกสารนี้/i)).toBeInTheDocument();
});
it('ควรซ่อนปุ่มล้างประวัติการสนทนาเมื่อไม่มีข้อความ', () => {
render(
<AiChatPanel
context={mockContext}
isOpen={true}
onClose={mockOnClose}
onToggle={mockOnToggle}
/>
);
expect(screen.queryByTitle('ล้างประวัติการสนทนา')).not.toBeInTheDocument();
});
it('ควรแสดงปุ่มล้างประวัติการสนทนาเมื่อมีข้อความในประวัติและคลิกเพื่อล้างข้อมูลได้', () => {
vi.mocked(useAiChat).mockReturnValue({
messages: [
{ id: '1', role: 'user', content: 'สวัสดี', timestamp: '2026-05-19T00:00:00.000Z' }
],
sendMessage: mockSendMessage,
clearHistory: mockClearHistory,
isLoading: false,
isOpen: false,
setIsOpen: vi.fn(),
toggleOpen: vi.fn(),
});
render(
<AiChatPanel
context={mockContext}
isOpen={true}
onClose={mockOnClose}
onToggle={mockOnToggle}
/>
);
const clearBtn = screen.getByTitle('ล้างประวัติการสนทนา');
expect(clearBtn).toBeInTheDocument();
fireEvent.click(clearBtn);
expect(mockClearHistory).toHaveBeenCalledTimes(1);
});
it('ควรเรียก onClose เมื่อคลิกปุ่มปิด', () => {
render(
<AiChatPanel
context={mockContext}
isOpen={true}
onClose={mockOnClose}
onToggle={mockOnToggle}
/>
);
const closeBtn = screen.getByTitle('ปิดหน้าต่างแชท');
fireEvent.click(closeBtn);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('ควรตอบสนองต่อปุ่ม Suggested Action ที่ถูกส่งจากกล่องแชท AI', () => {
vi.mocked(useAiChat).mockReturnValue({
messages: [
{
id: '2',
role: 'assistant',
content: 'ลองคลิกตัวเลือกต่อไปนี้:',
timestamp: '2026-05-19T00:00:00.000Z',
suggestedActions: [{ label: 'สรุปสถานะ RFA', query: 'ช่วยสรุปสถานะ RFA นี้ให้หน่อย' }]
}
],
sendMessage: mockSendMessage,
clearHistory: mockClearHistory,
isLoading: false,
isOpen: false,
setIsOpen: vi.fn(),
toggleOpen: vi.fn(),
});
render(
<AiChatPanel
context={mockContext}
isOpen={true}
onClose={mockOnClose}
onToggle={mockOnToggle}
/>
);
const actionBtn = screen.getByText('สรุปสถานะ RFA');
expect(actionBtn).toBeInTheDocument();
fireEvent.click(actionBtn);
expect(mockSendMessage).toHaveBeenCalledWith('ช่วยสรุปสถานะ RFA นี้ให้หน่อย');
});
});
+70
View File
@@ -0,0 +1,70 @@
// File: frontend/components/ai/ai-chat-input.tsx
// Change Log:
// - 2026-05-19: สร้างคอมโพเนนต์สำหรับรับข้อมูลข้อความ (Chat Input)
'use client';
import { useState, useRef, useEffect } from 'react';
import { Send, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
interface AiChatInputProps {
onSend: (text: string) => void;
isLoading: boolean;
}
export function AiChatInput({ onSend, isLoading }: AiChatInputProps) {
const [value, setValue] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleSubmit = () => {
const trimmed = value.trim();
if (trimmed && !isLoading) {
onSend(trimmed);
setValue('');
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`;
}
}, [value]);
return (
<div className="relative flex items-end gap-2 border-t bg-card p-3">
<div className="relative flex-1">
<Textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value.slice(0, 500))}
onKeyDown={handleKeyDown}
placeholder="ถาม AI เกี่ยวกับเอกสารนี้... (Enter เพื่อส่ง, Shift+Enter เพื่อขึ้นบรรทัดใหม่)"
className="min-h-[40px] max-h-[120px] resize-none pr-12 text-sm focus-visible:ring-1 focus-visible:ring-violet-500 rounded-lg py-2.5"
disabled={isLoading}
/>
<div className="absolute bottom-1 right-2 text-[10px] text-muted-foreground select-none">
{value.length}/500
</div>
</div>
<Button
onClick={handleSubmit}
disabled={!value.trim() || isLoading}
size="icon"
className="h-10 w-10 shrink-0 rounded-lg bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-700 hover:to-indigo-700 text-white"
aria-label="ส่งข้อความ"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
// File: frontend/components/ai/ai-chat-messages.tsx
// Change Log:
// - 2026-05-19: สร้างคอมโพเนนต์แสดงผลประวัติการสนทนาและการตอบสนองของ AI
'use client';
import { useRef, useEffect } from 'react';
import { Bot, User, AlertCircle, Loader2, Sparkles } from 'lucide-react';
import { ChatMessage } from '@/types/ai-chat';
import { ScrollArea } from '@/components/ui/scroll-area';
interface AiChatMessagesProps {
messages: ChatMessage[];
isLoading: boolean;
onSuggestedActionClick: (query: string) => void;
}
export function AiChatMessages({ messages, isLoading, onSuggestedActionClick }: AiChatMessagesProps) {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, isLoading]);
const parseMarkdown = (text: string) => {
if (!text) return null;
const lines = text.split('\n');
let inCodeBlock = false;
let codeBlockContent: string[] = [];
const elements: React.ReactNode[] = [];
const renderInline = (lineText: string, key: string) => {
const parts = lineText.split(/(\*\*.*?\*\*|`.*?`)/g);
return (
<span key={key}>
{parts.map((part, index) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={index} className="font-semibold text-foreground">{part.slice(2, -2)}</strong>;
}
if (part.startsWith('`') && part.endsWith('`')) {
return <code key={index} className="px-1.5 py-0.5 rounded bg-muted font-mono text-xs text-violet-600">{part.slice(1, -1)}</code>;
}
return part;
})}
</span>
);
};
lines.forEach((line, index) => {
const trimmed = line.trim();
if (trimmed.startsWith('```')) {
if (inCodeBlock) {
elements.push(
<pre key={`code-${index}`} className="bg-muted border rounded-lg p-3 text-xs font-mono overflow-x-auto my-2 text-foreground whitespace-pre">
<code>{codeBlockContent.join('\n')}</code>
</pre>
);
codeBlockContent = [];
inCodeBlock = false;
} else {
inCodeBlock = true;
}
return;
}
if (inCodeBlock) {
codeBlockContent.push(line);
return;
}
if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
elements.push(
<li key={`li-${index}`} className="ml-5 list-disc my-1 text-sm leading-relaxed">
{renderInline(trimmed.slice(2), `li-inline-${index}`)}
</li>
);
return;
}
if (/^\d+\.\s/.test(trimmed)) {
const dotIndex = trimmed.indexOf('.');
elements.push(
<li key={`ol-${index}`} className="ml-5 list-decimal my-1 text-sm leading-relaxed">
{renderInline(trimmed.slice(dotIndex + 1).trim(), `ol-inline-${index}`)}
</li>
);
return;
}
if (trimmed.startsWith('#')) {
const hashCount = (trimmed.match(/^#+/) || [''])[0].length;
const headerText = trimmed.replace(/^#+\s*/, '');
if (hashCount === 1) elements.push(<h1 key={`h1-${index}`} className="text-lg font-bold mt-3 mb-1 text-foreground">{renderInline(headerText, `h1-inline-${index}`)}</h1>);
else if (hashCount === 2) elements.push(<h2 key={`h2-${index}`} className="text-base font-bold mt-2 mb-1 text-foreground">{renderInline(headerText, `h2-inline-${index}`)}</h2>);
else elements.push(<h3 key={`h3-${index}`} className="text-sm font-semibold mt-2 mb-1 text-foreground">{renderInline(headerText, `h3-inline-${index}`)}</h3>);
return;
}
if (trimmed === '') {
elements.push(<div key={`br-${index}`} className="h-2" />);
return;
}
elements.push(
<p key={`p-${index}`} className="text-sm leading-relaxed my-1">
{renderInline(line, `p-inline-${index}`)}
</p>
);
});
return <div className="space-y-1">{elements}</div>;
};
return (
<ScrollArea className="flex-1 p-4 bg-muted/30">
<div className="space-y-4">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center text-center py-12 px-4">
<div className="h-12 w-12 rounded-2xl bg-gradient-to-tr from-violet-500 to-indigo-500 flex items-center justify-center text-white mb-3 shadow-md">
<Sparkles className="h-6 w-6 animate-pulse" />
</div>
<h3 className="text-sm font-semibold text-foreground"> AI</h3>
<p className="text-xs text-muted-foreground mt-1 max-w-[280px]">
</p>
</div>
)}
{messages.map((message) => {
const isUser = message.role === 'user';
const isError = message.content === 'ไม่สามารถเชื่อมต่อ AI ได้ กรุณาลองใหม่';
return (
<div key={message.id} className={`flex gap-3 ${isUser ? 'justify-end' : 'justify-start'}`}>
{!isUser && (
<div className={`h-8 w-8 rounded-lg shrink-0 flex items-center justify-center shadow-sm ${isError ? 'bg-destructive/10 text-destructive' : 'bg-gradient-to-tr from-violet-500 to-indigo-500 text-white'}`}>
{isError ? <AlertCircle className="h-4 w-4" /> : <Bot className="h-4 w-4" />}
</div>
)}
<div className={`flex flex-col max-w-[80%] gap-1.5`}>
<div className={`rounded-2xl px-4 py-2.5 shadow-sm text-sm ${isUser ? 'bg-violet-600 text-white rounded-tr-none font-medium' : isError ? 'bg-destructive/10 border border-destructive/20 text-destructive rounded-tl-none' : 'bg-card border text-card-foreground rounded-tl-none'}`}>
{message.isStreaming ? (
<div className="flex items-center gap-2 text-muted-foreground select-none py-1">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span className="text-xs">AI ...</span>
</div>
) : isUser ? (
<p className="whitespace-pre-wrap leading-relaxed">{message.content}</p>
) : (
parseMarkdown(message.content)
)}
</div>
{!isUser && message.suggestedActions && message.suggestedActions.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1">
{message.suggestedActions.map((action, idx) => (
<button
key={`${action.label}-${idx}`}
onClick={() => onSuggestedActionClick(action.query)}
className="text-xs px-2.5 py-1 rounded-full border border-violet-200 bg-violet-50/50 text-violet-700 hover:bg-violet-100 transition-colors font-medium select-none"
>
{action.label}
</button>
))}
</div>
)}
</div>
{isUser && (
<div className="h-8 w-8 rounded-lg shrink-0 bg-secondary flex items-center justify-center text-secondary-foreground shadow-sm">
<User className="h-4 w-4" />
</div>
)}
</div>
);
})}
{isLoading && messages.length > 0 && !messages[messages.length - 1].isStreaming && (
<div className="flex gap-3 justify-start">
<div className="h-8 w-8 rounded-lg shrink-0 bg-gradient-to-tr from-violet-500 to-indigo-500 text-white flex items-center justify-center shadow-sm">
<Bot className="h-4 w-4" />
</div>
<div className="rounded-2xl px-4 py-2.5 shadow-sm bg-card border text-card-foreground rounded-tl-none flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span className="text-xs">AI ...</span>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
</ScrollArea>
);
}
+96
View File
@@ -0,0 +1,96 @@
// File: frontend/components/ai/ai-chat-panel.tsx
// Change Log:
// - 2026-05-19: สร้างคอมโพเนนต์หลักสำหรับแผงแชท AI (AI Chat Panel) ด้านข้าง
'use client';
import { useEffect } from 'react';
import { X, Trash2, Bot, Sparkles } from 'lucide-react';
import { useAiChat } from '@/hooks/use-ai-chat';
import { AiChatMessages } from '@/components/ai/ai-chat-messages';
import { AiChatInput } from '@/components/ai/ai-chat-input';
import { Button } from '@/components/ui/button';
import { ChatContext } from '@/types/ai-chat';
interface AiChatPanelProps {
context: ChatContext;
isOpen: boolean;
onClose: () => void;
onToggle?: () => void;
}
export function AiChatPanel({ context, isOpen, onClose, onToggle }: AiChatPanelProps) {
const {
messages,
sendMessage,
clearHistory,
isLoading,
} = useAiChat(context);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === '.') {
e.preventDefault();
onToggle?.();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onToggle]);
const handleSuggestedAction = (queryText: string) => {
void sendMessage(queryText);
};
return (
<div
className={`fixed z-40 bg-background shadow-2xl transition-transform duration-300 ease-in-out flex flex-col border-t rounded-t-3xl bottom-0 top-auto right-0 left-0 w-full h-[60%] lg:top-0 lg:bottom-auto lg:right-0 lg:left-auto lg:h-full lg:w-[400px] lg:border-l lg:border-t-0 lg:rounded-t-none ${
isOpen
? 'translate-x-0 translate-y-0'
: 'translate-x-0 translate-y-full lg:translate-x-full lg:translate-y-0'
}`}
>
<div className="flex h-16 items-center justify-between border-b px-4 bg-gradient-to-r from-violet-50/50 to-indigo-50/50 dark:from-violet-950/20 dark:to-indigo-950/20">
<div className="flex items-center gap-2">
<div className="relative flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-tr from-violet-600 to-indigo-600 text-white shadow-md">
<Bot className="h-4 w-4" />
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-background bg-green-500" />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground flex items-center gap-1">
AI
<Sparkles className="h-3.5 w-3.5 text-violet-500 animate-pulse" />
</h2>
<p className="text-[10px] text-muted-foreground font-medium"></p>
</div>
</div>
<div className="flex items-center gap-1.5">
{messages.length > 0 && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:text-destructive text-muted-foreground rounded-lg"
onClick={clearHistory}
title="ล้างประวัติการสนทนา"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-muted text-muted-foreground rounded-lg"
onClick={onClose}
title="ปิดหน้าต่างแชท"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<AiChatMessages
messages={messages}
isLoading={isLoading}
onSuggestedActionClick={handleSuggestedAction}
/>
<AiChatInput onSend={sendMessage} isLoading={isLoading} />
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
// File: frontend/components/ai/ai-chat-toggle.tsx
// Change Log:
// - 2026-05-19: สร้างคอมโพเนนต์ปุ่มเปิด/ปิด AI Document Chat Panel
'use client';
import { MessageSquare, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface AiChatToggleProps {
isOpen: boolean;
onClick: () => void;
}
export function AiChatToggle({ isOpen, onClick }: AiChatToggleProps) {
return (
<Button
onClick={onClick}
className={`fixed bottom-6 right-6 z-50 h-14 w-14 rounded-full shadow-lg transition-all duration-300 ease-in-out hover:scale-115 flex items-center justify-center ${
isOpen
? 'bg-destructive hover:bg-destructive/95 text-destructive-foreground rotate-90'
: 'bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-700 hover:to-indigo-700 text-white hover:shadow-violet-500/20'
}`}
size="icon"
aria-label={isOpen ? 'ปิดช่องแชท AI' : 'เปิดช่องแชท AI'}
>
{isOpen ? (
<X className="h-6 w-6 transition-transform duration-200" />
) : (
<MessageSquare className="h-6 w-6 transition-transform duration-200" />
)}
</Button>
);
}
@@ -0,0 +1,67 @@
// File: components/ai/intent-classification/analytics/analytics-summary-cards.tsx
// Change Log
// - 2026-05-19: สร้าง Summary Cards สำหรับ Analytics Dashboard (T036, US3).
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { ClassificationAnalytics } from '@/lib/services/ai-intent.service';
interface AnalyticsSummaryCardsProps {
data: ClassificationAnalytics;
}
/**
* แสดงสรุปสถิติหลักในรูปแบบ Cards
* Total Requests, Pattern Hit Rate, Avg Confidence, Avg Latency
*/
export function AnalyticsSummaryCards({ data }: AnalyticsSummaryCardsProps) {
const cards = [
{
title: 'Total Requests',
value: data.totalRequests.toLocaleString(),
subtitle: `${data.successCount} สำเร็จ / ${data.failedCount} ล้มเหลว`,
color: 'text-blue-600',
},
{
title: 'Pattern Hit Rate',
value: `${data.patternHitRate}%`,
subtitle: 'เป้าหมาย: 70-80%',
color: data.patternHitRate >= 70 ? 'text-green-600' : 'text-amber-600',
},
{
title: 'Avg Confidence',
value: data.avgConfidence.toFixed(2),
subtitle: 'เป้าหมาย: ≥ 0.70',
color: data.avgConfidence >= 0.7 ? 'text-green-600' : 'text-amber-600',
},
{
title: 'Avg Latency',
value: `${data.avgLatencyMs.toFixed(1)}ms`,
subtitle: 'Pattern < 10ms, LLM < 2000ms',
color: data.avgLatencyMs < 100 ? 'text-green-600' : 'text-amber-600',
},
];
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{cards.map((card) => (
<Card key={card.title}>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{card.title}
</CardTitle>
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${card.color}`}>
{card.value}
</div>
<p className="text-xs text-muted-foreground mt-1">
{card.subtitle}
</p>
</CardContent>
</Card>
))}
</div>
);
}
@@ -0,0 +1,71 @@
// File: components/ai/intent-classification/analytics/intent-breakdown-table.tsx
// Change Log
// - 2026-05-19: สร้าง Intent Breakdown Table สำหรับ Analytics Dashboard (T036, US3).
'use client';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Progress } from '@/components/ui/progress';
import type { IntentStats } from '@/lib/services/ai-intent.service';
interface IntentBreakdownTableProps {
data: IntentStats[];
}
/**
* ตารางแสดงสถิติแยกตาม intent code พร้อม bar แสดง pattern vs llm
*/
export function IntentBreakdownTable({ data }: IntentBreakdownTableProps) {
if (data.length === 0) {
return <p className="text-sm text-muted-foreground"></p>;
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Intent Code</TableHead>
<TableHead className="text-right">Total</TableHead>
<TableHead className="text-right">Pattern</TableHead>
<TableHead className="text-right">LLM</TableHead>
<TableHead className="text-right">Avg Confidence</TableHead>
<TableHead className="w-[120px]">Pattern Rate</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((row) => {
const patternRate =
row.count > 0 ? (row.patternHits / row.count) * 100 : 0;
return (
<TableRow key={row.intentCode}>
<TableCell className="font-mono text-sm">
{row.intentCode}
</TableCell>
<TableCell className="text-right">{row.count}</TableCell>
<TableCell className="text-right">{row.patternHits}</TableCell>
<TableCell className="text-right">{row.llmHits}</TableCell>
<TableCell className="text-right">
{row.avgConfidence.toFixed(2)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress value={patternRate} className="h-2" />
<span className="text-xs text-muted-foreground w-10 text-right">
{patternRate.toFixed(0)}%
</span>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}
@@ -0,0 +1,78 @@
// File: components/ai/intent-classification/analytics/method-breakdown-table.tsx
// Change Log
// - 2026-05-19: สร้าง Method Breakdown Table สำหรับ Analytics Dashboard (T036, US3).
'use client';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import type { MethodStats } from '@/lib/services/ai-intent.service';
interface MethodBreakdownTableProps {
data: MethodStats[];
}
/** แปลงชื่อ method เป็น label + สี */
function methodBadge(method: string) {
switch (method) {
case 'pattern':
return <Badge variant="default">Pattern Match</Badge>;
case 'llm_fallback':
return <Badge variant="secondary">LLM Fallback</Badge>;
case 'semaphore_overflow':
return <Badge variant="destructive">Semaphore Overflow</Badge>;
case 'llm_error':
return <Badge variant="destructive">LLM Error</Badge>;
default:
return <Badge variant="outline">{method}</Badge>;
}
}
/**
* ตารางแสดงสถิติแยกตาม method (pattern, llm_fallback, etc.)
*/
export function MethodBreakdownTable({ data }: MethodBreakdownTableProps) {
if (data.length === 0) {
return <p className="text-sm text-muted-foreground"></p>;
}
const total = data.reduce((sum, d) => sum + d.count, 0);
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Method</TableHead>
<TableHead className="text-right">Count</TableHead>
<TableHead className="text-right">%</TableHead>
<TableHead className="text-right">Avg Confidence</TableHead>
<TableHead className="text-right">Avg Latency</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((row) => (
<TableRow key={row.method}>
<TableCell>{methodBadge(row.method)}</TableCell>
<TableCell className="text-right">{row.count}</TableCell>
<TableCell className="text-right">
{total > 0 ? ((row.count / total) * 100).toFixed(1) : 0}%
</TableCell>
<TableCell className="text-right">
{row.avgConfidence.toFixed(2)}
</TableCell>
<TableCell className="text-right">
{row.avgLatencyMs.toFixed(1)}ms
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
@@ -0,0 +1,78 @@
// File: components/ai/intent-classification/analytics/recalibration-panel.tsx
// Change Log
// - 2026-05-19: สร้าง Recalibration Panel สำหรับ Analytics Dashboard (T036, US3).
'use client';
import { AlertTriangle } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { RecalibrationRecommendation } from '@/lib/services/ai-intent.service';
interface RecalibrationPanelProps {
data: RecalibrationRecommendation[];
}
/**
* แสดงคำแนะนำ Intent ที่ควรเพิ่ม pattern เพื่อลด LLM Calls
* ตาม SC-001: เป้าหมาย Pattern Hit Rate 70-80%
*/
export function RecalibrationPanel({ data }: RecalibrationPanelProps) {
if (data.length === 0) {
return (
<Alert>
<AlertTitle></AlertTitle>
<AlertDescription>
Intent Pattern Pattern Hit Rate
</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-3">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle> Pattern</AlertTitle>
<AlertDescription>
Intent classify LLM keyword/regex pattern
LLM
</AlertDescription>
</Alert>
<Table>
<TableHeader>
<TableRow>
<TableHead>Intent Code</TableHead>
<TableHead className="text-right">LLM Calls</TableHead>
<TableHead className="text-right">Avg Confidence</TableHead>
<TableHead className="text-right">Priority</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((row) => (
<TableRow key={row.intentCode}>
<TableCell className="font-mono text-sm">
{row.intentCode}
</TableCell>
<TableCell className="text-right">{row.llmCallCount}</TableCell>
<TableCell className="text-right">
{row.avgConfidence.toFixed(2)}
</TableCell>
<TableCell className="text-right font-medium text-amber-600">
{row.priority}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,90 @@
'use client';
// File: components/ai/intent-classification/classification-result-card.tsx
// Change Log
// - 2026-05-19: สร้าง Classification Result Card component (ADR-024).
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import type { ClassificationResult } from '@/lib/services/ai-intent.service';
interface ClassificationResultCardProps {
query: string;
result: ClassificationResult;
}
/** สีของ method badge */
const METHOD_COLORS: Record<string, string> = {
pattern: 'bg-green-100 text-green-800 border-green-200',
llm_fallback: 'bg-blue-100 text-blue-800 border-blue-200',
semaphore_overflow: 'bg-yellow-100 text-yellow-800 border-yellow-200',
llm_error: 'bg-red-100 text-red-800 border-red-200',
};
/** สีของ confidence bar */
function getConfidenceColor(confidence: number): string {
if (confidence >= 0.9) return 'bg-green-500';
if (confidence >= 0.7) return 'bg-blue-500';
if (confidence >= 0.5) return 'bg-yellow-500';
return 'bg-red-500';
}
/**
* Card แสดงผลลัพธ์การจำแนก Intent
* แสดง intentCode, confidence, method, latency
*/
export function ClassificationResultCard({
query,
result,
}: ClassificationResultCardProps) {
const confidencePercent = Math.round(result.confidence * 100);
return (
<Card className="border-l-4 border-l-primary/20">
<CardContent className="pt-4 pb-3 space-y-2">
{/* Query */}
<p className="text-sm font-medium text-muted-foreground">
&quot;{query}&quot;
</p>
{/* Intent Code + Method */}
<div className="flex items-center justify-between">
<span className="font-mono text-base font-semibold">
{result.intentCode}
</span>
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={METHOD_COLORS[result.method] || ''}
>
{result.method}
</Badge>
<span className="text-xs text-muted-foreground">
{result.latencyMs}ms
</span>
</div>
</div>
{/* Confidence Bar */}
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${getConfidenceColor(result.confidence)}`}
style={{ width: `${confidencePercent}%` }}
/>
</div>
<span className="text-xs font-mono text-muted-foreground w-10 text-right">
{confidencePercent}%
</span>
</div>
{/* Params (ถ้ามี) */}
{result.params && Object.keys(result.params).length > 0 && (
<pre className="text-xs bg-muted p-2 rounded overflow-x-auto">
{JSON.stringify(result.params, null, 2)}
</pre>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,148 @@
'use client';
// File: components/ai/intent-classification/intent-form.tsx
// Change Log
// - 2026-05-19: สร้าง Intent Definition Form (Create/Update) (ADR-024).
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import type {
IntentDefinition,
IntentCategory,
} from '@/lib/services/ai-intent.service';
interface IntentFormProps {
open: boolean;
onClose: () => void;
onSubmit: (data: {
intentCode: string;
descriptionTh: string;
descriptionEn: string;
category: IntentCategory;
}) => void;
/** ถ้ามี = edit mode */
initial?: IntentDefinition;
isLoading?: boolean;
}
/**
* Dialog Form สำหรับสร้าง/แก้ไข Intent Definition
*/
export function IntentForm({
open,
onClose,
onSubmit,
initial,
isLoading,
}: IntentFormProps) {
const isEdit = !!initial;
const [intentCode, setIntentCode] = useState(initial?.intentCode || '');
const [descriptionTh, setDescriptionTh] = useState(initial?.descriptionTh || '');
const [descriptionEn, setDescriptionEn] = useState(initial?.descriptionEn || '');
const [category, setCategory] = useState<IntentCategory>(initial?.category || 'read');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({ intentCode, descriptionTh, descriptionEn, category });
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{isEdit ? `แก้ไข ${initial.intentCode}` : 'สร้าง Intent ใหม่'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Intent Code */}
<div className="space-y-1">
<Label htmlFor="intentCode">Intent Code</Label>
<Input
id="intentCode"
value={intentCode}
onChange={(e) => setIntentCode(e.target.value.toUpperCase())}
placeholder="GET_RFA"
pattern="^[A-Z][A-Z0-9_]*$"
maxLength={50}
disabled={isEdit}
required
/>
<p className="text-xs text-muted-foreground">
UPPERCASE_SNAKE_CASE GET_RFA, SUMMARIZE_DOCUMENT
</p>
</div>
{/* Description TH */}
<div className="space-y-1">
<Label htmlFor="descriptionTh"> ()</Label>
<Input
id="descriptionTh"
value={descriptionTh}
onChange={(e) => setDescriptionTh(e.target.value)}
placeholder="ดึง RFA ตาม filter"
maxLength={255}
required
/>
</div>
{/* Description EN */}
<div className="space-y-1">
<Label htmlFor="descriptionEn">Description (EN)</Label>
<Input
id="descriptionEn"
value={descriptionEn}
onChange={(e) => setDescriptionEn(e.target.value)}
placeholder="Get RFA by filters"
maxLength={255}
required
/>
</div>
{/* Category */}
<div className="space-y-1">
<Label>Category</Label>
<Select
value={category}
onValueChange={(v) => setCategory(v as IntentCategory)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="read">Read ()</SelectItem>
<SelectItem value="suggest">Suggest ()</SelectItem>
<SelectItem value="utility">Utility ( )</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
</Button>
<Button type="submit" disabled={isLoading}>
{isEdit ? 'บันทึก' : 'สร้าง'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,182 @@
'use client';
// File: components/ai/intent-classification/pattern-form.tsx
// Change Log
// - 2026-05-19: สร้าง Pattern Form (Create/Update) (ADR-024).
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import type {
IntentPattern,
PatternType,
PatternLanguage,
} from '@/lib/services/ai-intent.service';
interface PatternFormProps {
open: boolean;
onClose: () => void;
onSubmit: (data: {
patternType: PatternType;
patternValue: string;
language?: PatternLanguage;
priority?: number;
}) => void;
/** ถ้ามี = edit mode */
initial?: IntentPattern;
isLoading?: boolean;
}
/**
* Dialog Form สำหรับสร้าง/แก้ไข Intent Pattern
*/
export function PatternForm({
open,
onClose,
onSubmit,
initial,
isLoading,
}: PatternFormProps) {
const isEdit = !!initial;
const [patternType, setPatternType] = useState<PatternType>(
initial?.patternType || 'keyword'
);
const [patternValue, setPatternValue] = useState(initial?.patternValue || '');
const [language, setLanguage] = useState<PatternLanguage>(
initial?.language || 'any'
);
const [priority, setPriority] = useState<number>(initial?.priority || 100);
const [regexError, setRegexError] = useState<string | null>(null);
/** Validate regex ใน frontend ก่อนส่ง */
const validateRegex = (value: string): boolean => {
if (patternType !== 'regex') return true;
try {
new RegExp(value);
setRegexError(null);
return true;
} catch (err) {
setRegexError(err instanceof Error ? err.message : 'Invalid regex');
return false;
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateRegex(patternValue)) return;
onSubmit({ patternType, patternValue, language, priority });
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{isEdit ? 'แก้ไข Pattern' : 'เพิ่ม Pattern ใหม่'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Pattern Type */}
<div className="space-y-1">
<Label> Pattern</Label>
<Select
value={patternType}
onValueChange={(v) => {
setPatternType(v as PatternType);
setRegexError(null);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="keyword">Keyword (includes)</SelectItem>
<SelectItem value="regex">Regex (RegExp)</SelectItem>
</SelectContent>
</Select>
</div>
{/* Pattern Value */}
<div className="space-y-1">
<Label htmlFor="patternValue">
Pattern {patternType === 'regex' && '(Regular Expression)'}
</Label>
<Input
id="patternValue"
value={patternValue}
onChange={(e) => {
setPatternValue(e.target.value);
if (patternType === 'regex') validateRegex(e.target.value);
}}
placeholder={
patternType === 'keyword'
? 'สรุป, drawing, rfa'
: '\\brfa\\b'
}
maxLength={255}
required
/>
{regexError && (
<p className="text-xs text-destructive">{regexError}</p>
)}
</div>
{/* Language */}
<div className="space-y-1">
<Label></Label>
<Select
value={language}
onValueChange={(v) => setLanguage(v as PatternLanguage)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">Any ()</SelectItem>
<SelectItem value="th">Thai ()</SelectItem>
<SelectItem value="en">English ()</SelectItem>
</SelectContent>
</Select>
</div>
{/* Priority */}
<div className="space-y-1">
<Label htmlFor="priority">Priority ( = )</Label>
<Input
id="priority"
type="number"
value={priority}
onChange={(e) => setPriority(Number(e.target.value))}
min={1}
max={9999}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
</Button>
<Button type="submit" disabled={isLoading || !!regexError}>
{isEdit ? 'บันทึก' : 'เพิ่ม'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,99 @@
'use client';
// File: components/ai/intent-classification/test-console-panel.tsx
// Change Log
// - 2026-05-19: สร้าง Test Console Panel สำหรับทดสอบ Intent Classification (ADR-024).
import { useState } from 'react';
import { useClassifyIntent } from '@/hooks/ai/use-intent-classification';
import { ClassificationResultCard } from './classification-result-card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Loader2, Send } from 'lucide-react';
import type { ClassificationResult } from '@/lib/services/ai-intent.service';
/**
* Test Console Panel — Admin/Developer ทดสอบ classification แบบ real-time
*/
export function TestConsolePanel() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<
Array<{ query: string; result: ClassificationResult; timestamp: Date }>
>([]);
const classifyMutation = useClassifyIntent();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = query.trim();
if (!trimmed) return;
try {
const result = await classifyMutation.mutateAsync({ query: trimmed });
setResults((prev) => [
{ query: trimmed, result, timestamp: new Date() },
...prev,
]);
setQuery('');
} catch {
// Error state จัดการโดย TanStack Query (classifyMutation.isError)
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Send className="h-5 w-5" />
Test Console
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Input Form */}
<form onSubmit={handleSubmit} className="flex gap-2">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="พิมพ์คำถามเพื่อทดสอบ เช่น 'สรุปเอกสารนี้' หรือ 'show me RFA'"
maxLength={200}
disabled={classifyMutation.isPending}
/>
<Button
type="submit"
disabled={!query.trim() || classifyMutation.isPending}
>
{classifyMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</form>
{/* Error Display */}
{classifyMutation.isError && (
<p className="text-sm text-destructive">
เกิดข้อผิดพลาด: ไม่สามารถเชื่อมต่อ AI
</p>
)}
{/* Results List */}
<div className="space-y-3 max-h-[60vh] overflow-y-auto">
{results.length === 0 && (
<p className="text-muted-foreground text-sm text-center py-8">
Intent Classification
</p>
)}
{results.map((item, idx) => (
<ClassificationResultCard
key={`${item.timestamp.getTime()}-${idx}`}
query={item.query}
result={item.result}
/>
))}
</div>
</CardContent>
</Card>
);
}