690519:1631 224 to 226 AI #01
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 นี้ให้หน่อย');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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; // ระยะเวลาประมวลผล (มิลลิวินาที)
|
||||
}
|
||||
Reference in New Issue
Block a user