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,178 @@
'use client';
// File: app/(admin)/admin/ai/intent-classification/[intentCode]/page.tsx
// Change Log
// - 2026-05-19: สร้างหน้า Intent Detail + Patterns (Admin, ADR-024).
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import {
useIntentDefinition,
useUpdateIntentDefinition,
useIntentPatterns,
useCreateIntentPattern,
useDeleteIntentPattern,
} from '@/hooks/ai/use-intent-classification';
import { PatternForm } from '@/components/ai/intent-classification/pattern-form';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ArrowLeft, Plus, Trash2 } from 'lucide-react';
import type { PatternType, PatternLanguage } from '@/lib/services/ai-intent.service';
export default function IntentDetailPage() {
const params = useParams();
const router = useRouter();
const intentCode = params.intentCode as string;
const [showPatternForm, setShowPatternForm] = useState(false);
const { data: definition, isLoading: defLoading } = useIntentDefinition(intentCode);
const updateMutation = useUpdateIntentDefinition(intentCode);
const { data: patterns, isLoading: patternsLoading } = useIntentPatterns(intentCode);
const createPatternMutation = useCreateIntentPattern(intentCode);
const deletePatternMutation = useDeleteIntentPattern(intentCode);
const handleToggleActive = async (isActive: boolean) => {
await updateMutation.mutateAsync({ isActive });
};
const handleCreatePattern = async (data: {
patternType: PatternType;
patternValue: string;
language?: PatternLanguage;
priority?: number;
}) => {
await createPatternMutation.mutateAsync(data);
setShowPatternForm(false);
};
const handleDeletePattern = async (publicId: string) => {
if (!confirm('ต้องการลบ Pattern นี้?')) return;
await deletePatternMutation.mutateAsync(publicId);
};
if (defLoading) {
return <p className="text-center py-8 text-muted-foreground">...</p>;
}
if (!definition) {
return <p className="text-center py-8 text-destructive"> Intent: {intentCode}</p>;
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.push('/admin/ai/intent-classification')}
>
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="flex-1">
<h1 className="text-2xl font-bold font-mono">{definition.intentCode}</h1>
<p className="text-muted-foreground">{definition.descriptionTh}</p>
<p className="text-sm text-muted-foreground">{definition.descriptionEn}</p>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Active</span>
<Switch
checked={definition.isActive}
onCheckedChange={handleToggleActive}
/>
</div>
</div>
{/* Info Card */}
<Card>
<CardContent className="pt-4 flex gap-4">
<Badge variant="secondary">{definition.category}</Badge>
<span className="text-sm text-muted-foreground">
: {new Date(definition.createdAt).toLocaleString('th-TH')}
</span>
</CardContent>
</Card>
{/* Patterns Table */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Patterns ({patterns?.length || 0})</CardTitle>
<Button size="sm" onClick={() => setShowPatternForm(true)}>
<Plus className="h-4 w-4 mr-1" />
Pattern
</Button>
</CardHeader>
<CardContent>
{patternsLoading ? (
<p className="text-center text-muted-foreground py-4">...</p>
) : patterns && patterns.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Pattern Value</TableHead>
<TableHead>Language</TableHead>
<TableHead>Priority</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{patterns.map((p) => (
<TableRow key={p.publicId}>
<TableCell>
<Badge variant="outline">{p.patternType}</Badge>
</TableCell>
<TableCell className="font-mono text-sm max-w-[200px] truncate">
{p.patternValue}
</TableCell>
<TableCell>{p.language}</TableCell>
<TableCell>{p.priority}</TableCell>
<TableCell>
<Badge variant={p.isActive ? 'default' : 'destructive'}>
{p.isActive ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeletePattern(p.publicId)}
disabled={deletePatternMutation.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="text-center text-muted-foreground py-4">
Pattern Pattern Matching
</p>
)}
</CardContent>
</Card>
{/* Create Pattern Form Dialog */}
<PatternForm
open={showPatternForm}
onClose={() => setShowPatternForm(false)}
onSubmit={handleCreatePattern}
isLoading={createPatternMutation.isPending}
/>
</div>
);
}
@@ -0,0 +1,96 @@
// File: app/(admin)/admin/ai/intent-classification/analytics/page.tsx
// Change Log
// - 2026-05-19: สร้างหน้า Analytics Dashboard สำหรับ Intent Classification (T037, US3).
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { useIntentAnalytics } from '@/hooks/ai/use-intent-classification';
import { AnalyticsSummaryCards } from '@/components/ai/intent-classification/analytics/analytics-summary-cards';
import { MethodBreakdownTable } from '@/components/ai/intent-classification/analytics/method-breakdown-table';
import { IntentBreakdownTable } from '@/components/ai/intent-classification/analytics/intent-breakdown-table';
import { RecalibrationPanel } from '@/components/ai/intent-classification/analytics/recalibration-panel';
/**
* หน้า Analytics Dashboard สำหรับ Intent Classification
* แสดง Summary Cards, Method Breakdown, Intent Breakdown, Recalibration
*/
export default function IntentAnalyticsPage() {
const { data, isLoading, isError, error } = useIntentAnalytics();
if (isLoading) {
return (
<div className="space-y-6 p-6">
<h1 className="text-2xl font-bold">Intent Classification Analytics</h1>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-[120px]" />
))}
</div>
<Skeleton className="h-[300px]" />
</div>
);
}
if (isError) {
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">Intent Classification Analytics</h1>
<Card>
<CardContent className="p-6">
<p className="text-destructive">
: {error instanceof Error ? error.message : 'ไม่สามารถโหลดข้อมูลได้'}
</p>
</CardContent>
</Card>
</div>
);
}
if (!data) {
return null;
}
return (
<div className="space-y-6 p-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Intent Classification Analytics</h1>
<p className="text-sm text-muted-foreground"> 30 </p>
</div>
{/* Summary Cards */}
<AnalyticsSummaryCards data={data} />
{/* Method Breakdown */}
<Card>
<CardHeader>
<CardTitle>Classification Method Breakdown</CardTitle>
</CardHeader>
<CardContent>
<MethodBreakdownTable data={data.byMethod} />
</CardContent>
</Card>
{/* Intent Breakdown */}
<Card>
<CardHeader>
<CardTitle>Intent Code Breakdown</CardTitle>
</CardHeader>
<CardContent>
<IntentBreakdownTable data={data.byIntent} />
</CardContent>
</Card>
{/* Recalibration */}
<Card>
<CardHeader>
<CardTitle>Recalibration Recommendations</CardTitle>
</CardHeader>
<CardContent>
<RecalibrationPanel data={data.recalibration} />
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,151 @@
'use client';
// File: app/(admin)/admin/ai/intent-classification/page.tsx
// Change Log
// - 2026-05-19: สร้างหน้า Intent Definitions List (Admin, ADR-024).
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
useIntentDefinitions,
useCreateIntentDefinition,
} from '@/hooks/ai/use-intent-classification';
import { IntentForm } from '@/components/ai/intent-classification/intent-form';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Plus, Brain, TestTube } from 'lucide-react';
import type { IntentCategory } from '@/lib/services/ai-intent.service';
/** สีของ category badge */
const CATEGORY_COLORS: Record<IntentCategory, string> = {
read: 'bg-blue-100 text-blue-800',
suggest: 'bg-purple-100 text-purple-800',
utility: 'bg-gray-100 text-gray-800',
};
export default function IntentClassificationPage() {
const router = useRouter();
const [showForm, setShowForm] = useState(false);
const { data: definitions, isLoading } = useIntentDefinitions();
const createMutation = useCreateIntentDefinition();
const handleCreate = async (data: {
intentCode: string;
descriptionTh: string;
descriptionEn: string;
category: IntentCategory;
}) => {
await createMutation.mutateAsync(data);
setShowForm(false);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Brain className="h-6 w-6" />
Intent Classification
</h1>
<p className="text-muted-foreground mt-1">
Intent Definitions Patterns AI Chat
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => router.push('/admin/ai/intent-classification/test-console')}
>
<TestTube className="h-4 w-4 mr-2" />
Test Console
</Button>
<Button onClick={() => setShowForm(true)}>
<Plus className="h-4 w-4 mr-2" />
Intent
</Button>
</div>
</div>
{/* Table */}
<Card>
<CardHeader>
<CardTitle>Intent Definitions ({definitions?.length || 0})</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-center text-muted-foreground py-8">...</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Intent Code</TableHead>
<TableHead></TableHead>
<TableHead>Category</TableHead>
<TableHead></TableHead>
<TableHead>Patterns</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{definitions?.map((def) => (
<TableRow
key={def.publicId}
className="cursor-pointer hover:bg-muted/50"
onClick={() =>
router.push(
`/admin/ai/intent-classification/${def.intentCode}`
)
}
>
<TableCell className="font-mono font-medium">
{def.intentCode}
</TableCell>
<TableCell>
<div className="text-sm">{def.descriptionTh}</div>
<div className="text-xs text-muted-foreground">
{def.descriptionEn}
</div>
</TableCell>
<TableCell>
<Badge
variant="secondary"
className={CATEGORY_COLORS[def.category]}
>
{def.category}
</Badge>
</TableCell>
<TableCell>
<Badge variant={def.isActive ? 'default' : 'destructive'}>
{def.isActive ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell className="text-center">
{def.patterns?.length || 0}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Create Form Dialog */}
<IntentForm
open={showForm}
onClose={() => setShowForm(false)}
onSubmit={handleCreate}
isLoading={createMutation.isPending}
/>
</div>
);
}
@@ -0,0 +1,38 @@
'use client';
// File: app/(admin)/admin/ai/intent-classification/test-console/page.tsx
// Change Log
// - 2026-05-19: สร้างหน้า Test Console สำหรับทดสอบ Intent Classification (ADR-024).
import { useRouter } from 'next/navigation';
import { TestConsolePanel } from '@/components/ai/intent-classification/test-console-panel';
import { Button } from '@/components/ui/button';
import { ArrowLeft } from 'lucide-react';
export default function TestConsolePage() {
const router = useRouter();
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.push('/admin/ai/intent-classification')}
>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-2xl font-bold">Intent Test Console</h1>
<p className="text-muted-foreground">
Intent Classification Real-time
</p>
</div>
</div>
{/* Test Console */}
<TestConsolePanel />
</div>
);
}
@@ -18,6 +18,8 @@ import { contractDrawingService } from '@/lib/services/contract-drawing.service'
import { shopDrawingService } from '@/lib/services/shop-drawing.service';
import { asBuiltDrawingService } from '@/lib/services/asbuilt-drawing.service';
import { useUpdateContractDrawing, useUploadRevision } from '@/hooks/use-drawing';
import { AiChatToggle } from '@/components/ai/ai-chat-toggle';
import { AiChatPanel } from '@/components/ai/ai-chat-panel';
type DrawingType = 'CONTRACT' | 'SHOP' | 'AS_BUILT';
@@ -78,6 +80,7 @@ export default function DrawingDetailPage({ params }: { params: Promise<{ uuid:
const searchParams = useSearchParams();
const isEditMode = searchParams.get('edit') === 'true';
const isUploadMode = searchParams.get('upload') === 'true';
const [isChatOpen, setIsChatOpen] = useState(false);
const { data: drawing, isLoading } = useQuery({
queryKey: ['drawing-detail', uuid],
@@ -120,7 +123,8 @@ export default function DrawingDetailPage({ params }: { params: Promise<{ uuid:
const revisions = drawing.revisions || [];
return (
<div className="space-y-6">
<div className={`relative transition-all duration-300 ${isChatOpen ? 'lg:pr-[400px]' : ''}`}>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
@@ -232,6 +236,14 @@ export default function DrawingDetailPage({ params }: { params: Promise<{ uuid:
</div>
</div>
)}
</div>
<AiChatToggle isOpen={isChatOpen} onClick={() => setIsChatOpen(!isChatOpen)} />
<AiChatPanel
context={{ type: 'drawing', publicId: uuid }}
isOpen={isChatOpen}
onClose={() => setIsChatOpen(false)}
onToggle={() => setIsChatOpen((prev) => !prev)}
/>
</div>
);
}
+13 -1
View File
@@ -12,6 +12,8 @@ import { Loader2 } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { RFA } from '@/types/rfa';
import type { WorkflowAttachmentSummary } from '@/types/workflow';
import { AiChatToggle } from '@/components/ai/ai-chat-toggle';
import { AiChatPanel } from '@/components/ai/ai-chat-panel';
export default function RFADetailPage() {
const { uuid } = useParams();
@@ -32,6 +34,7 @@ export default function RFADetailPage() {
const [pendingAttachmentIds, setPendingAttachmentIds] = useState<string[]>([]);
// ADR-021 T041: ติดตาม publicIds ที่ Storage แจ้ง 404
const [unavailableIds, setUnavailableIds] = useState<string[]>([]);
const [isChatOpen, setIsChatOpen] = useState(false);
const handleUnavailable = (publicId: string) =>
setUnavailableIds((prev) => [...new Set([...prev, publicId])]);
@@ -56,7 +59,8 @@ export default function RFADetailPage() {
const status = currentRevision?.statusCode?.statusCode ?? '';
return (
<div className="space-y-4">
<div className={`relative transition-all duration-300 ${isChatOpen ? 'lg:pr-[400px]' : ''}`}>
<div className="space-y-4">
{/* ADR-021: Integrated Banner — เลขเอกสาร + สถานะ + ปุ่ม Action */}
<IntegratedBanner
docNo={docNo}
@@ -101,6 +105,14 @@ export default function RFADetailPage() {
onUnavailable={handleUnavailable}
/>
</WorkflowErrorBoundary>
</div>
<AiChatToggle isOpen={isChatOpen} onClick={() => setIsChatOpen(!isChatOpen)} />
<AiChatPanel
context={{ type: 'rfa', publicId: uuidStr }}
isOpen={isChatOpen}
onClose={() => setIsChatOpen(false)}
onToggle={() => setIsChatOpen((prev) => !prev)}
/>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
// File: frontend/app/api/ai/chat/route.ts
// Change Log:
// - 2026-05-19: สร้าง API Proxy สำหรับ AI Document Chat
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
export async function POST(req: NextRequest) {
const session = await auth();
if (!session || !session.accessToken) {
return NextResponse.json({ error: { message: 'ไม่มีสิทธิ์เข้าถึงระบบ' } }, { status: 401 });
}
try {
const body = await req.json();
const { query, context } = body;
if (!query || !context || !context.type || !context.publicId) {
return NextResponse.json({ error: { message: 'ข้อมูลนำเข้าไม่ถูกต้อง' } }, { status: 400 });
}
const backendUrl = (process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api') + '/ai/chat';
const response = await fetch(backendUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.accessToken}`,
},
body: JSON.stringify({ query, context }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return NextResponse.json(errorData, { status: response.status });
}
const data = await response.json();
return NextResponse.json(data);
} catch (_error) {
return NextResponse.json(
{
error: {
type: 'INTERNAL_ERROR',
code: 'PROXY_ERROR',
message: 'เกิดข้อผิดพลาดในการประมวลผลคำขอ',
severity: 'HIGH',
timestamp: new Date().toISOString(),
},
},
{ status: 500 }
);
}
}
@@ -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>
);
}
@@ -0,0 +1,76 @@
// File: frontend/hooks/__tests__/use-ai-chat.test.ts
// Change Log:
// - 2026-05-19: สร้าง Unit Test สำหรับ useAiChat Hook
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { createTestQueryClient } from '@/lib/test-utils';
import { useAiChat } from '../use-ai-chat';
import axios from 'axios';
vi.mock('axios');
describe('useAiChat hook', () => {
const mockContext = { type: 'rfa', publicId: '019505a1-7c3e-7000-8000-abc123def456' };
beforeEach(() => {
vi.clearAllMocks();
if (typeof window !== 'undefined') {
sessionStorage.clear();
}
});
it('ควรตั้งค่าสถานะเริ่มต้นให้ถูกต้อง', () => {
const { wrapper } = createTestQueryClient();
const { result } = renderHook(() => useAiChat(mockContext), { wrapper });
expect(result.current.messages).toEqual([]);
expect(result.current.isOpen).toBe(false);
expect(result.current.isLoading).toBe(false);
});
it('ควรสามารถส่งข้อความและรับคำตอบจาก AI สำเร็จ', async () => {
const mockResponse = {
data: {
content: 'สวัสดีครับ ผมคือผู้ช่วย AI RFA',
messageId: 'assistant-1',
suggestedActions: [{ label: 'ปุ่มแนะนำ', query: 'คำสั่งแนะนำ' }],
},
};
vi.mocked(axios.post).mockResolvedValue(mockResponse);
const { wrapper } = createTestQueryClient();
const { result } = renderHook(() => useAiChat(mockContext), { wrapper });
await act(async () => {
void result.current.sendMessage('สวัสดีครับ');
});
expect(result.current.messages.length).toBe(2);
expect(result.current.messages[0].role).toBe('user');
expect(result.current.messages[0].content).toBe('สวัสดีครับ');
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.messages.length).toBe(2);
expect(result.current.messages[1].role).toBe('assistant');
expect(result.current.messages[1].content).toBe('สวัสดีครับ ผมคือผู้ช่วย AI RFA');
expect(result.current.messages[1].suggestedActions).toEqual([{ label: 'ปุ่มแนะนำ', query: 'คำสั่งแนะนำ' }]);
});
it('ควรทำงานถูกต้องเมื่อเกิดข้อผิดพลาดในการเรียก API', async () => {
vi.mocked(axios.post).mockRejectedValue(new Error('Network error'));
const { wrapper } = createTestQueryClient();
const { result } = renderHook(() => useAiChat(mockContext), { wrapper });
await act(async () => {
void result.current.sendMessage('สวัสดี');
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.messages[1].content).toContain('ไม่สามารถเชื่อมต่อ AI ได้');
});
it('ควรสามารถล้างประวัติการสนทนาได้', async () => {
const { wrapper } = createTestQueryClient();
const { result } = renderHook(() => useAiChat(mockContext), { wrapper });
act(() => {
result.current.sendMessage('สวัสดี');
});
act(() => {
result.current.clearHistory();
});
expect(result.current.messages).toEqual([]);
});
});
@@ -0,0 +1,239 @@
// File: hooks/ai/__tests__/use-intent-classification.test.ts
// Change Log
// - 2026-05-19: สร้าง Unit tests สำหรับ Intent Classification hooks (T038).
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { createTestQueryClient } from '@/lib/test-utils';
import {
useIntentDefinitions,
useIntentDefinition,
useIntentPatterns,
useCreateIntentDefinition,
useClassifyIntent,
} from '../use-intent-classification';
import { aiIntentService } from '@/lib/services/ai-intent.service';
// Mock service
vi.mock('@/lib/services/ai-intent.service', () => ({
aiIntentService: {
getDefinitions: vi.fn(),
getDefinition: vi.fn(),
getPatterns: vi.fn(),
createDefinition: vi.fn(),
updateDefinition: vi.fn(),
createPattern: vi.fn(),
updatePattern: vi.fn(),
deletePattern: vi.fn(),
classify: vi.fn(),
},
}));
describe('use-intent-classification hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useIntentDefinitions', () => {
it('ควรดึง definitions สำเร็จ', async () => {
const mockData = [
{ publicId: 'uuid-1', intentCode: 'GET_RFA', category: 'read' },
{ publicId: 'uuid-2', intentCode: 'SUMMARIZE_DOCUMENT', category: 'read' },
];
vi.mocked(aiIntentService.getDefinitions).mockResolvedValue(mockData);
const { wrapper } = createTestQueryClient();
const { result } = renderHook(() => useIntentDefinitions(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockData);
expect(aiIntentService.getDefinitions).toHaveBeenCalledWith(undefined);
});
it('ควรส่ง filter params ไปด้วย', async () => {
vi.mocked(aiIntentService.getDefinitions).mockResolvedValue([]);
const { wrapper } = createTestQueryClient();
renderHook(
() => useIntentDefinitions({ category: 'read', isActive: true }),
{ wrapper },
);
await waitFor(() => {
expect(aiIntentService.getDefinitions).toHaveBeenCalledWith({
category: 'read',
isActive: true,
});
});
});
});
describe('useIntentDefinition', () => {
it('ควรดึง definition ตาม intentCode', async () => {
const mockDef = {
publicId: 'uuid-1',
intentCode: 'GET_RFA',
descriptionTh: 'ดึง RFA',
descriptionEn: 'Get RFA',
category: 'read',
isActive: true,
};
vi.mocked(aiIntentService.getDefinition).mockResolvedValue(mockDef);
const { wrapper } = createTestQueryClient();
const { result } = renderHook(
() => useIntentDefinition('GET_RFA'),
{ wrapper },
);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockDef);
expect(aiIntentService.getDefinition).toHaveBeenCalledWith('GET_RFA');
});
it('ควรไม่ fetch เมื่อ intentCode เป็นค่าว่าง', () => {
const { wrapper } = createTestQueryClient();
const { result } = renderHook(
() => useIntentDefinition(''),
{ wrapper },
);
// enabled: !!intentCode → false → ไม่ fetch
expect(result.current.fetchStatus).toBe('idle');
expect(aiIntentService.getDefinition).not.toHaveBeenCalled();
});
});
describe('useIntentPatterns', () => {
it('ควรดึง patterns ตาม intentCode', async () => {
const mockPatterns = [
{ publicId: 'p-1', intentCode: 'GET_RFA', patternType: 'keyword', patternValue: 'rfa' },
];
vi.mocked(aiIntentService.getPatterns).mockResolvedValue(mockPatterns);
const { wrapper } = createTestQueryClient();
const { result } = renderHook(
() => useIntentPatterns('GET_RFA'),
{ wrapper },
);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockPatterns);
});
});
describe('useCreateIntentDefinition', () => {
it('ควรเรียก createDefinition สำเร็จ', async () => {
const newDef = {
intentCode: 'TEST_INTENT',
descriptionTh: 'ทดสอบ',
descriptionEn: 'Test',
category: 'utility' as const,
};
vi.mocked(aiIntentService.createDefinition).mockResolvedValue({
publicId: 'new-uuid',
...newDef,
isActive: true,
});
const { wrapper } = createTestQueryClient();
const { result } = renderHook(
() => useCreateIntentDefinition(),
{ wrapper },
);
result.current.mutate(newDef);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(aiIntentService.createDefinition).toHaveBeenCalledWith(newDef);
});
});
describe('useClassifyIntent', () => {
it('ควร classify query สำเร็จ', async () => {
const mockResult = {
intentCode: 'SUMMARIZE_DOCUMENT',
confidence: 1.0,
method: 'pattern',
latencyMs: 3,
};
vi.mocked(aiIntentService.classify).mockResolvedValue(mockResult);
const { wrapper } = createTestQueryClient();
const { result } = renderHook(
() => useClassifyIntent(),
{ wrapper },
);
result.current.mutate({ query: 'สรุปเอกสาร' });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockResult);
expect(aiIntentService.classify).toHaveBeenCalledWith('สรุปเอกสาร', undefined);
});
it('ควรส่ง projectPublicId ไปด้วย (ถ้ามี)', async () => {
vi.mocked(aiIntentService.classify).mockResolvedValue({
intentCode: 'GET_RFA',
confidence: 0.9,
method: 'llm_fallback',
latencyMs: 500,
});
const { wrapper } = createTestQueryClient();
const { result } = renderHook(
() => useClassifyIntent(),
{ wrapper },
);
result.current.mutate({
query: 'show rfa',
projectPublicId: 'proj-uuid-123',
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(aiIntentService.classify).toHaveBeenCalledWith('show rfa', 'proj-uuid-123');
});
it('ควร handle error state', async () => {
vi.mocked(aiIntentService.classify).mockRejectedValue(new Error('Network error'));
const { wrapper } = createTestQueryClient();
const { result } = renderHook(
() => useClassifyIntent(),
{ wrapper },
);
result.current.mutate({ query: 'test' });
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toBeInstanceOf(Error);
});
});
});
@@ -0,0 +1,133 @@
// File: hooks/ai/use-intent-classification.ts
// Change Log
// - 2026-05-19: สร้าง TanStack Query hooks สำหรับ Intent Classification (ADR-024).
// Hooks สำหรับ Intent Classification — ใช้ TanStack Query
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
aiIntentService,
IntentCategory,
CreateIntentDefinitionDto,
UpdateIntentDefinitionDto,
CreateIntentPatternDto,
UpdateIntentPatternDto,
ClassificationAnalytics,
} from '@/lib/services/ai-intent.service';
// === Query Keys ===
const KEYS = {
definitions: ['ai', 'intent-definitions'] as const,
definition: (code: string) => ['ai', 'intent-definitions', code] as const,
patterns: (code: string) => ['ai', 'intent-patterns', code] as const,
analytics: ['ai', 'intent-analytics'] as const,
};
// === Query Hooks ===
/** ดึง Intent Definitions ทั้งหมด */
export function useIntentDefinitions(params?: {
category?: IntentCategory;
isActive?: boolean;
}) {
return useQuery({
queryKey: [...KEYS.definitions, params],
queryFn: () => aiIntentService.getDefinitions(params),
});
}
/** ดึง Intent Definition ตาม intentCode */
export function useIntentDefinition(intentCode: string) {
return useQuery({
queryKey: KEYS.definition(intentCode),
queryFn: () => aiIntentService.getDefinition(intentCode),
enabled: !!intentCode,
});
}
/** ดึง Patterns ตาม intentCode */
export function useIntentPatterns(intentCode: string) {
return useQuery({
queryKey: KEYS.patterns(intentCode),
queryFn: () => aiIntentService.getPatterns(intentCode),
enabled: !!intentCode,
});
}
// === Mutation Hooks ===
/** สร้าง Intent Definition ใหม่ */
export function useCreateIntentDefinition() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: CreateIntentDefinitionDto) =>
aiIntentService.createDefinition(dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: KEYS.definitions });
},
});
}
/** อัปเดต Intent Definition */
export function useUpdateIntentDefinition(intentCode: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: UpdateIntentDefinitionDto) =>
aiIntentService.updateDefinition(intentCode, dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: KEYS.definitions });
queryClient.invalidateQueries({ queryKey: KEYS.definition(intentCode) });
},
});
}
/** สร้าง Pattern ใหม่ */
export function useCreateIntentPattern(intentCode: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: CreateIntentPatternDto) =>
aiIntentService.createPattern(intentCode, dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: KEYS.patterns(intentCode) });
},
});
}
/** อัปเดต Pattern */
export function useUpdateIntentPattern(intentCode: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { publicId: string; dto: UpdateIntentPatternDto }) =>
aiIntentService.updatePattern(data.publicId, data.dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: KEYS.patterns(intentCode) });
},
});
}
/** ลบ Pattern (soft delete) */
export function useDeleteIntentPattern(intentCode: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (publicId: string) => aiIntentService.deletePattern(publicId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: KEYS.patterns(intentCode) });
},
});
}
/** ดึง Classification Analytics */
export function useIntentAnalytics(params?: { from?: string; to?: string }) {
return useQuery<ClassificationAnalytics>({
queryKey: [...KEYS.analytics, params],
queryFn: () => aiIntentService.getAnalytics(params),
staleTime: 60_000, // 1 นาที cache
});
}
/** Classify query (สำหรับ Test Console) */
export function useClassifyIntent() {
return useMutation({
mutationFn: (data: { query: string; projectPublicId?: string }) =>
aiIntentService.classify(data.query, data.projectPublicId),
});
}
+96
View File
@@ -0,0 +1,96 @@
// File: frontend/hooks/use-ai-chat.ts
// Change Log:
// - 2026-05-19: พัฒนา Hook useAiChat สำหรับระบบแชท AI ในหน้าเอกสาร
import { useState, useEffect, useCallback } from 'react';
import { useMutation } from '@tanstack/react-query';
import axios from 'axios';
import { ChatMessage, ChatContext, ChatResponseDto } from '@/types/ai-chat';
export function useAiChat(context: ChatContext) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isOpen, setIsOpen] = useState<boolean>(false);
const storageKey = `ai_chat_session_${context.type}_${context.publicId}`;
useEffect(() => {
if (typeof window !== 'undefined') {
const stored = sessionStorage.getItem(storageKey);
if (stored) {
try {
setMessages(JSON.parse(stored));
} catch (_) {
setMessages([]);
}
} else {
setMessages([]);
}
}
}, [storageKey]);
const saveMessages = useCallback((newMsgs: ChatMessage[]) => {
setMessages(newMsgs);
if (typeof window !== 'undefined') {
sessionStorage.setItem(storageKey, JSON.stringify(newMsgs));
}
}, [storageKey]);
const chatMutation = useMutation({
mutationFn: async (queryText: string): Promise<ChatResponseDto> => {
const response = await axios.post('/api/ai/chat', {
query: queryText,
context,
});
return response.data;
},
});
const sendMessage = useCallback(async (queryText: string) => {
if (!queryText.trim()) return;
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content: queryText,
timestamp: new Date().toISOString(),
};
const currentMsgs = [...messages, userMsg];
saveMessages(currentMsgs);
const systemLoadingMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: '',
timestamp: new Date().toISOString(),
isStreaming: true,
};
setMessages([...currentMsgs, systemLoadingMsg]);
try {
const result = await chatMutation.mutateAsync(queryText);
const assistantMsg: ChatMessage = {
id: result.messageId || crypto.randomUUID(),
role: 'assistant',
content: result.content,
timestamp: new Date().toISOString(),
suggestedActions: result.suggestedActions,
};
saveMessages([...currentMsgs, assistantMsg]);
} catch (_err) {
const errorMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: 'ไม่สามารถเชื่อมต่อ AI ได้ กรุณาลองใหม่',
timestamp: new Date().toISOString(),
};
saveMessages([...currentMsgs, errorMsg]);
}
}, [messages, saveMessages, chatMutation]);
const clearHistory = useCallback(() => {
saveMessages([]);
}, [saveMessages]);
const toggleOpen = useCallback(() => {
setIsOpen((prev) => !prev);
}, []);
return {
messages,
sendMessage,
clearHistory,
isLoading: chatMutation.isPending,
isOpen,
setIsOpen,
toggleOpen,
};
}
+231
View File
@@ -0,0 +1,231 @@
// File: lib/services/ai-intent.service.ts
// Change Log
// - 2026-05-19: สร้าง API client สำหรับ Intent Classification (ADR-024).
// Service สำหรับเรียก Intent Classification API (Admin + Classify)
import api from '../api/client';
// Helper: แกะ nested data wrapper จาก TransformInterceptor
const extractData = <T>(value: unknown): T => {
if (value && typeof value === 'object' && 'data' in value) {
return (value as { data: T }).data;
}
return value as T;
};
// === Types ===
/** หมวดหมู่ Intent */
export type IntentCategory = 'read' | 'suggest' | 'utility';
/** ชนิด Pattern */
export type PatternType = 'keyword' | 'regex';
/** ภาษา Pattern */
export type PatternLanguage = 'th' | 'en' | 'any';
/** วิธีที่ใช้จำแนก */
export type ClassificationMethod =
| 'pattern'
| 'llm_fallback'
| 'semaphore_overflow'
| 'llm_error';
/** Intent Definition */
export interface IntentDefinition {
publicId: string;
intentCode: string;
descriptionTh: string;
descriptionEn: string;
category: IntentCategory;
isActive: boolean;
createdAt: string;
updatedAt: string;
patterns?: IntentPattern[];
}
/** Intent Pattern */
export interface IntentPattern {
publicId: string;
intentCode: string;
language: PatternLanguage;
patternType: PatternType;
patternValue: string;
priority: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
/** ผลลัพธ์การจำแนก Intent */
export interface ClassificationResult {
intentCode: string;
confidence: number;
method: ClassificationMethod;
params?: Record<string, unknown>;
latencyMs: number;
}
/** DTO สำหรับสร้าง Intent Definition */
export interface CreateIntentDefinitionDto {
intentCode: string;
descriptionTh: string;
descriptionEn: string;
category: IntentCategory;
}
/** DTO สำหรับ update Intent Definition */
export interface UpdateIntentDefinitionDto {
descriptionTh?: string;
descriptionEn?: string;
isActive?: boolean;
}
/** DTO สำหรับสร้าง Pattern */
export interface CreateIntentPatternDto {
language?: PatternLanguage;
patternType: PatternType;
patternValue: string;
priority?: number;
}
/** สถิติแยกตาม method */
export interface MethodStats {
method: string;
count: number;
avgConfidence: number;
avgLatencyMs: number;
}
/** สถิติแยกตาม intent */
export interface IntentStats {
intentCode: string;
count: number;
avgConfidence: number;
patternHits: number;
llmHits: number;
}
/** คำแนะนำ Recalibration */
export interface RecalibrationRecommendation {
intentCode: string;
llmCallCount: number;
avgConfidence: number;
priority: number;
}
/** ผลลัพธ์ Analytics รวม */
export interface ClassificationAnalytics {
totalRequests: number;
successCount: number;
failedCount: number;
patternHitRate: number;
avgConfidence: number;
avgLatencyMs: number;
byMethod: MethodStats[];
byIntent: IntentStats[];
recalibration: RecalibrationRecommendation[];
}
/** DTO สำหรับ update Pattern */
export interface UpdateIntentPatternDto {
language?: PatternLanguage;
patternType?: PatternType;
patternValue?: string;
priority?: number;
isActive?: boolean;
}
// === API Client ===
export const aiIntentService = {
// --- Classification ---
/** จำแนก Intent จาก user query */
classify: async (query: string, projectPublicId?: string): Promise<ClassificationResult> => {
const { data } = await api.post('/ai/intent/classify', {
query,
projectPublicId,
});
return extractData<ClassificationResult>(data);
},
// --- Intent Definitions (Admin) ---
/** ดึงรายการ Intent Definitions ทั้งหมด */
getDefinitions: async (params?: {
category?: IntentCategory;
isActive?: boolean;
}): Promise<IntentDefinition[]> => {
const { data } = await api.get('/admin/ai/intent-definitions', { params });
const result = extractData<{ data: IntentDefinition[] } | IntentDefinition[]>(data);
return Array.isArray(result) ? result : result.data;
},
/** ดึง Intent Definition ตาม intentCode */
getDefinition: async (intentCode: string): Promise<IntentDefinition> => {
const { data } = await api.get(`/admin/ai/intent-definitions/${intentCode}`);
return extractData<IntentDefinition>(data);
},
/** สร้าง Intent Definition ใหม่ */
createDefinition: async (dto: CreateIntentDefinitionDto): Promise<IntentDefinition> => {
const { data } = await api.post('/admin/ai/intent-definitions', dto);
return extractData<IntentDefinition>(data);
},
/** อัปเดต Intent Definition */
updateDefinition: async (
intentCode: string,
dto: UpdateIntentDefinitionDto
): Promise<IntentDefinition> => {
const { data } = await api.patch(`/admin/ai/intent-definitions/${intentCode}`, dto);
return extractData<IntentDefinition>(data);
},
// --- Intent Patterns (Admin) ---
/** ดึง Patterns ตาม intentCode */
getPatterns: async (intentCode: string): Promise<IntentPattern[]> => {
const { data } = await api.get(`/admin/ai/intent-definitions/${intentCode}/patterns`);
const result = extractData<{ data: IntentPattern[] } | IntentPattern[]>(data);
return Array.isArray(result) ? result : result.data;
},
/** สร้าง Pattern ใหม่ */
createPattern: async (
intentCode: string,
dto: CreateIntentPatternDto
): Promise<IntentPattern> => {
const { data } = await api.post(
`/admin/ai/intent-definitions/${intentCode}/patterns`,
dto
);
return extractData<IntentPattern>(data);
},
/** อัปเดต Pattern */
updatePattern: async (
publicId: string,
dto: UpdateIntentPatternDto
): Promise<IntentPattern> => {
const { data } = await api.patch(`/admin/ai/intent-patterns/${publicId}`, dto);
return extractData<IntentPattern>(data);
},
/** Soft delete Pattern */
deletePattern: async (publicId: string): Promise<void> => {
await api.delete(`/admin/ai/intent-patterns/${publicId}`);
},
// --- Analytics (Admin) ---
/** ดึงสถิติ Classification Analytics */
getAnalytics: async (params?: {
from?: string;
to?: string;
}): Promise<ClassificationAnalytics> => {
const { data } = await api.get('/admin/ai/intent-analytics', { params });
return extractData<ClassificationAnalytics>(data);
},
};
+48
View File
@@ -0,0 +1,48 @@
{
"intent_classification": {
"title": "Intent Classification",
"description": "Manage Intent Definitions and Patterns for AI Chat",
"create_intent": "Create Intent",
"edit_intent": "Edit Intent",
"test_console": "Test Console",
"test_console_description": "Test Intent Classification in real-time",
"test_console_placeholder": "Type a question to test, e.g. 'summarize this document'",
"test_console_empty": "Type a question above to test Intent Classification",
"intent_code": "Intent Code",
"intent_code_hint": "UPPERCASE_SNAKE_CASE e.g. GET_RFA, SUMMARIZE_DOCUMENT",
"description_th": "Description (Thai)",
"description_en": "Description (EN)",
"category": "Category",
"category_read": "Read (Fetch data)",
"category_suggest": "Suggest (Recommendations)",
"category_utility": "Utility (Others)",
"patterns": "Patterns",
"add_pattern": "Add Pattern",
"edit_pattern": "Edit Pattern",
"pattern_type": "Pattern Type",
"pattern_type_keyword": "Keyword (includes)",
"pattern_type_regex": "Regex (RegExp)",
"pattern_value": "Pattern Value",
"pattern_language": "Language",
"language_any": "Any (All languages)",
"language_th": "Thai",
"language_en": "English",
"priority": "Priority",
"priority_hint": "Lower = more important",
"status_active": "Active",
"status_inactive": "Inactive",
"no_patterns": "No patterns yet — add one to enable Pattern Matching",
"method_pattern": "Pattern Match",
"method_llm_fallback": "LLM Fallback",
"method_semaphore_overflow": "Semaphore Overflow",
"method_llm_error": "LLM Error",
"confidence": "Confidence",
"latency": "Latency",
"cancel": "Cancel",
"save": "Save",
"create": "Create",
"delete_confirm": "Delete this pattern?",
"loading": "Loading...",
"not_found": "Intent not found"
}
}
+48
View File
@@ -0,0 +1,48 @@
{
"intent_classification": {
"title": "Intent Classification",
"description": "จัดการ Intent Definitions และ Patterns สำหรับ AI Chat",
"create_intent": "สร้าง Intent",
"edit_intent": "แก้ไข Intent",
"test_console": "Test Console",
"test_console_description": "ทดสอบ Intent Classification แบบ Real-time",
"test_console_placeholder": "พิมพ์คำถามเพื่อทดสอบ เช่น 'สรุปเอกสารนี้'",
"test_console_empty": "พิมพ์คำถามด้านบนเพื่อทดสอบ Intent Classification",
"intent_code": "Intent Code",
"intent_code_hint": "UPPERCASE_SNAKE_CASE เช่น GET_RFA, SUMMARIZE_DOCUMENT",
"description_th": "คำอธิบาย (ไทย)",
"description_en": "Description (EN)",
"category": "Category",
"category_read": "Read (ดึงข้อมูล)",
"category_suggest": "Suggest (แนะนำ)",
"category_utility": "Utility (อื่น ๆ)",
"patterns": "Patterns",
"add_pattern": "เพิ่ม Pattern",
"edit_pattern": "แก้ไข Pattern",
"pattern_type": "ชนิด Pattern",
"pattern_type_keyword": "Keyword (includes)",
"pattern_type_regex": "Regex (RegExp)",
"pattern_value": "ค่า Pattern",
"pattern_language": "ภาษา",
"language_any": "Any (ทุกภาษา)",
"language_th": "Thai (ภาษาไทย)",
"language_en": "English (ภาษาอังกฤษ)",
"priority": "Priority",
"priority_hint": "ต่ำ = สำคัญกว่า",
"status_active": "Active",
"status_inactive": "Inactive",
"no_patterns": "ยังไม่มี Pattern — เพิ่มเพื่อให้ Pattern Matching ทำงาน",
"method_pattern": "Pattern Match",
"method_llm_fallback": "LLM Fallback",
"method_semaphore_overflow": "Semaphore Overflow",
"method_llm_error": "LLM Error",
"confidence": "ความมั่นใจ",
"latency": "Latency",
"cancel": "ยกเลิก",
"save": "บันทึก",
"create": "สร้าง",
"delete_confirm": "ต้องการลบ Pattern นี้?",
"loading": "กำลังโหลด...",
"not_found": "ไม่พบ Intent"
}
}
+50
View File
@@ -0,0 +1,50 @@
// File: frontend/types/ai-chat.ts
// Change Log:
// - 2026-05-19: สร้างอินเตอร์เฟซและประเภทข้อมูลสำหรับระบบ AI Document Chat
/**
* ปุ่มสั่งการกระทำที่แนะนำจาก AI
*/
export interface SuggestedAction {
label: string; // ข้อความแสดงบนปุ่ม Chip
query: string; // คำค้นหาหรือคำสั่งที่จะส่งหา AI เมื่อคลิก
}
/**
* โครงสร้างข้อความสนทนาในแต่ละ Session
*/
export interface ChatMessage {
id: string; // รหัสเฉพาะของข้อความในรูปแบบ UUIDv7 string
role: 'user' | 'assistant' | 'system'; // บทบาทผู้ส่งข้อความ
content: string; // เนื้อหาข้อความ (Markdown format)
timestamp: string; // วันเวลาส่งข้อความในรูปแบบ ISO string
suggestedActions?: SuggestedAction[]; // ปุ่มสั่งการแนะนำ (ถ้ามี)
isStreaming?: boolean; // สถานะกำลังรอข้อความแบบ Stream
}
/**
* บริบทแนบของเอกสารที่คุยอยู่
*/
export interface ChatContext {
type: 'drawing' | 'rfa' | 'transmittal' | 'correspondence'; // ประเภทของเอกสาร
publicId: string; // UUIDv7 publicId ของเอกสารนั้นๆ
}
/**
* ข้อมูลคำขอส่งแชทไปยัง API
*/
export interface ChatRequestDto {
query: string; // คำถามของผู้ใช้งาน
context: ChatContext; // บริบทหน้าเอกสาร
}
/**
* ข้อมูลการตอบกลับแชทจาก API
*/
export interface ChatResponseDto {
messageId: string; // UUIDv7 ของข้อความตอบกลับ
role: 'assistant'; // บทบาทผู้ตอบ
content: string; // เนื้อหาของคำตอบ
suggestedActions?: SuggestedAction[]; // ปุ่มสั่งการแนะนำ
latencyMs: number; // ระยะเวลาประมวลผล (มิลลิวินาที)
}