690519:1631 224 to 226 AI #01
This commit is contained in:
@@ -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 นี้ให้หน่อย');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
"{query}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user