feat(ai): unify AI architecture, implement RAG and legacy migration
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
// File: app/(dashboard)/ai-staging/page.tsx
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่มหน้า AI staging queue สำหรับ human-in-the-loop review.
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { CheckCircle2, RefreshCcw } from 'lucide-react';
|
||||
import {
|
||||
AiStagingRecord,
|
||||
AiStagingStatus,
|
||||
useAiStagingQueue,
|
||||
useApproveAiStagingRecord,
|
||||
} from '@/lib/api/ai';
|
||||
import { projectService } from '@/lib/services/project.service';
|
||||
import { masterDataService } from '@/lib/services/master-data.service';
|
||||
import { organizationService } from '@/lib/services/organization.service';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AiStatusBanner } from '@/components/ai/AiStatusBanner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { useTranslations } from '@/hooks/use-translations';
|
||||
|
||||
interface ProjectOption {
|
||||
publicId?: string;
|
||||
projectCode?: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
interface OrganizationOption {
|
||||
publicId?: string;
|
||||
organizationCode?: string;
|
||||
organizationName?: string;
|
||||
}
|
||||
|
||||
interface CorrespondenceTypeOption {
|
||||
typeCode: string;
|
||||
typeName: string;
|
||||
}
|
||||
|
||||
const approveSchema = z.object({
|
||||
documentNumber: z.string().min(1),
|
||||
subject: z.string().min(1),
|
||||
categoryCode: z.string().min(1),
|
||||
projectPublicId: z.string().uuid(),
|
||||
senderOrganizationPublicId: z.string().uuid().optional(),
|
||||
receiverOrganizationPublicId: z.string().uuid().optional(),
|
||||
issuedDate: z.string().optional(),
|
||||
receivedDate: z.string().optional(),
|
||||
body: z.string().optional(),
|
||||
});
|
||||
|
||||
type ApproveFormValues = z.infer<typeof approveSchema>;
|
||||
|
||||
const getMetadataText = (
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
keys: string[]
|
||||
): string => {
|
||||
for (const key of keys) {
|
||||
const value = metadata?.[key];
|
||||
if (typeof value === 'string') return value;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
function getStatusVariant(
|
||||
status: AiStagingStatus
|
||||
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
if (status === AiStagingStatus.PENDING) return 'secondary';
|
||||
if (status === AiStagingStatus.REJECTED) return 'destructive';
|
||||
if (status === AiStagingStatus.IMPORTED) return 'default';
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
export default function AiStagingPage() {
|
||||
const t = useTranslations();
|
||||
const [selectedRecord, setSelectedRecord] = useState<AiStagingRecord | null>(
|
||||
null
|
||||
);
|
||||
const queueQuery = useAiStagingQueue();
|
||||
const approveMutation = useApproveAiStagingRecord();
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: ['ai-staging', 'projects'],
|
||||
queryFn: () => projectService.getAll({ isActive: true, limit: 100 }),
|
||||
});
|
||||
const organizationsQuery = useQuery({
|
||||
queryKey: ['ai-staging', 'organizations'],
|
||||
queryFn: () => organizationService.getAll({ isActive: true, limit: 200 }),
|
||||
});
|
||||
const typesQuery = useQuery({
|
||||
queryKey: ['ai-staging', 'correspondence-types'],
|
||||
queryFn: () => masterDataService.getCorrespondenceTypes(),
|
||||
});
|
||||
|
||||
const form = useForm<ApproveFormValues>({
|
||||
resolver: zodResolver(approveSchema),
|
||||
defaultValues: {
|
||||
documentNumber: '',
|
||||
subject: '',
|
||||
categoryCode: '',
|
||||
projectPublicId: '',
|
||||
senderOrganizationPublicId: undefined,
|
||||
receiverOrganizationPublicId: undefined,
|
||||
issuedDate: '',
|
||||
receivedDate: '',
|
||||
body: '',
|
||||
},
|
||||
});
|
||||
|
||||
const records = queueQuery.data?.items ?? [];
|
||||
const projects = useMemo(
|
||||
() => (Array.isArray(projectsQuery.data) ? (projectsQuery.data as ProjectOption[]) : []),
|
||||
[projectsQuery.data]
|
||||
);
|
||||
const organizations = useMemo(
|
||||
() =>
|
||||
Array.isArray(organizationsQuery.data)
|
||||
? (organizationsQuery.data as OrganizationOption[])
|
||||
: [],
|
||||
[organizationsQuery.data]
|
||||
);
|
||||
const correspondenceTypes = useMemo(
|
||||
() =>
|
||||
Array.isArray(typesQuery.data)
|
||||
? (typesQuery.data as CorrespondenceTypeOption[])
|
||||
: [],
|
||||
[typesQuery.data]
|
||||
);
|
||||
|
||||
const openApprovalDialog = (record: AiStagingRecord): void => {
|
||||
const metadata = record.extractedMetadata;
|
||||
setSelectedRecord(record);
|
||||
form.reset({
|
||||
documentNumber: getMetadataText(metadata, ['documentNumber', 'doc_number']),
|
||||
subject: getMetadataText(metadata, ['subject', 'title']),
|
||||
categoryCode: getMetadataText(metadata, ['categoryCode', 'category']),
|
||||
projectPublicId: '',
|
||||
senderOrganizationPublicId: undefined,
|
||||
receiverOrganizationPublicId: undefined,
|
||||
issuedDate: getMetadataText(metadata, ['issuedDate', 'issued_date']),
|
||||
receivedDate: getMetadataText(metadata, ['receivedDate', 'received_date']),
|
||||
body: getMetadataText(metadata, ['body', 'summary']),
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (values: ApproveFormValues): Promise<void> => {
|
||||
if (!selectedRecord) return;
|
||||
try {
|
||||
await approveMutation.mutateAsync({
|
||||
publicId: selectedRecord.publicId,
|
||||
payload: {
|
||||
...values,
|
||||
finalMetadata: values,
|
||||
},
|
||||
});
|
||||
toast.success(t('ai.staging.approveSuccess'));
|
||||
setSelectedRecord(null);
|
||||
} catch {
|
||||
toast.error(t('ai.staging.approveError'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">
|
||||
{t('ai.staging.title')}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('ai.staging.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void queueQuery.refetch()}
|
||||
disabled={queueQuery.isFetching}
|
||||
>
|
||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||
{t('ai.staging.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AiStatusBanner isOffline={queueQuery.isError} />
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('ai.staging.file')}</TableHead>
|
||||
<TableHead>{t('ai.staging.batch')}</TableHead>
|
||||
<TableHead>{t('ai.staging.confidence')}</TableHead>
|
||||
<TableHead>{t('ai.staging.status')}</TableHead>
|
||||
<TableHead className="w-[120px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map((record) => (
|
||||
<TableRow key={record.publicId}>
|
||||
<TableCell className="font-medium">
|
||||
{record.originalFileName}
|
||||
{record.errorReason ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{record.errorReason}
|
||||
</p>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>{record.batchId}</TableCell>
|
||||
<TableCell>
|
||||
{record.confidenceScore === undefined
|
||||
? t('ai.staging.empty')
|
||||
: `${Math.round(record.confidenceScore * 100)}%`}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={getStatusVariant(record.status)}>
|
||||
{record.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={record.status !== AiStagingStatus.PENDING}
|
||||
onClick={() => openApprovalDialog(record)}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{t('ai.staging.review')}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{records.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
|
||||
{queueQuery.isLoading
|
||||
? t('ai.staging.loading')
|
||||
: t('ai.staging.emptyQueue')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={selectedRecord !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedRecord(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('ai.staging.reviewTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="documentNumber">
|
||||
{t('ai.staging.documentNumber')}
|
||||
</Label>
|
||||
<Input id="documentNumber" {...form.register('documentNumber')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('ai.staging.category')}</Label>
|
||||
<Select
|
||||
value={form.watch('categoryCode')}
|
||||
onValueChange={(value) =>
|
||||
form.setValue('categoryCode', value, { shouldValidate: true })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('ai.staging.selectCategory')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{correspondenceTypes.map((type) => (
|
||||
<SelectItem key={type.typeCode} value={type.typeCode}>
|
||||
{type.typeName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">{t('ai.staging.subject')}</Label>
|
||||
<Input id="subject" {...form.register('subject')} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('ai.staging.project')}</Label>
|
||||
<Select
|
||||
value={form.watch('projectPublicId')}
|
||||
onValueChange={(value) =>
|
||||
form.setValue('projectPublicId', value, {
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('ai.staging.selectProject')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.map((project) => (
|
||||
<SelectItem
|
||||
key={project.publicId ?? project.projectCode}
|
||||
value={project.publicId ?? ''}
|
||||
>
|
||||
{project.projectName ?? project.projectCode}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('ai.staging.sender')}</Label>
|
||||
<Select
|
||||
value={form.watch('senderOrganizationPublicId') ?? ''}
|
||||
onValueChange={(value) =>
|
||||
form.setValue('senderOrganizationPublicId', value, {
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('ai.staging.selectSender')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{organizations.map((organization) => (
|
||||
<SelectItem
|
||||
key={organization.publicId ?? organization.organizationCode}
|
||||
value={organization.publicId ?? ''}
|
||||
>
|
||||
{organization.organizationName ??
|
||||
organization.organizationCode}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('ai.staging.receiver')}</Label>
|
||||
<Select
|
||||
value={form.watch('receiverOrganizationPublicId') ?? ''}
|
||||
onValueChange={(value) =>
|
||||
form.setValue('receiverOrganizationPublicId', value, {
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('ai.staging.selectReceiver')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{organizations.map((organization) => (
|
||||
<SelectItem
|
||||
key={organization.publicId ?? organization.organizationCode}
|
||||
value={organization.publicId ?? ''}
|
||||
>
|
||||
{organization.organizationName ??
|
||||
organization.organizationCode}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issuedDate">{t('ai.staging.issuedDate')}</Label>
|
||||
<Input id="issuedDate" type="date" {...form.register('issuedDate')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="receivedDate">
|
||||
{t('ai.staging.receivedDate')}
|
||||
</Label>
|
||||
<Input
|
||||
id="receivedDate"
|
||||
type="date"
|
||||
{...form.register('receivedDate')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="body">{t('ai.staging.body')}</Label>
|
||||
<Textarea id="body" rows={5} {...form.register('body')} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={approveMutation.isPending}>
|
||||
{t('ai.staging.approve')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// File: components/ai/AiStatusBanner.tsx
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม banner สำหรับ graceful degradation ของ AI staging.
|
||||
'use client';
|
||||
|
||||
import { AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { useTranslations } from '@/hooks/use-translations';
|
||||
|
||||
interface AiStatusBannerProps {
|
||||
isOffline: boolean;
|
||||
}
|
||||
|
||||
export function AiStatusBanner({ isOffline }: AiStatusBannerProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
if (isOffline) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>{t('ai.status.offlineTitle')}</AlertTitle>
|
||||
<AlertDescription>{t('ai.status.offlineDescription')}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<AlertTitle>{t('ai.status.onlineTitle')}</AlertTitle>
|
||||
<AlertDescription>{t('ai.status.onlineDescription')}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
// File: components/ai/RagChatWidget.tsx
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม RAG Chat Widget พร้อม BullMQ polling UI ตาม ADR-023 Phase 4 (T023).
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Send, X, Loader2, AlertTriangle, BookOpen } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
AiRagCitation,
|
||||
AiRagJobResult,
|
||||
useCancelRagJob,
|
||||
useRagJobStatus,
|
||||
useSubmitRagQuery,
|
||||
} from '@/lib/api/ai';
|
||||
|
||||
interface RagChatWidgetProps {
|
||||
/** publicId ของโครงการสำหรับ project-scoped vector search (FR-002) */
|
||||
projectPublicId: string;
|
||||
/** แสดง widget ในโหมด disabled เมื่อ AI host offline (FR-006) */
|
||||
isAiOffline?: boolean;
|
||||
}
|
||||
|
||||
/** แปลง status เป็น badge variant */
|
||||
function statusBadge(status: AiRagJobResult['status']): {
|
||||
label: string;
|
||||
variant: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||
} {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return { label: 'รอในคิว...', variant: 'outline' };
|
||||
case 'processing':
|
||||
return { label: 'กำลังประมวลผล...', variant: 'secondary' };
|
||||
case 'completed':
|
||||
return { label: 'เสร็จสิ้น', variant: 'default' };
|
||||
case 'failed':
|
||||
return { label: 'ล้มเหลว', variant: 'destructive' };
|
||||
case 'cancelled':
|
||||
return { label: 'ยกเลิกแล้ว', variant: 'outline' };
|
||||
default:
|
||||
return { label: status, variant: 'outline' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Widget สำหรับ RAG Conversational Q&A ผ่าน BullMQ polling (ADR-023 FR-009, FR-010, FR-011)
|
||||
* - ส่งคำถามเข้า /api/ai/rag/query → รับ requestPublicId
|
||||
* - Polling /api/ai/rag/jobs/:requestPublicId ทุก 2 วินาที
|
||||
* - แสดง status: pending → processing → completed/failed
|
||||
* - รองรับการยกเลิก job (FR-011)
|
||||
*/
|
||||
export function RagChatWidget({ projectPublicId, isAiOffline = false }: RagChatWidgetProps) {
|
||||
const [question, setQuestion] = useState('');
|
||||
const [activeRequestId, setActiveRequestId] = useState<string | null>(null);
|
||||
const [isPolling, setIsPolling] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const submitMutation = useSubmitRagQuery();
|
||||
const cancelMutation = useCancelRagJob();
|
||||
|
||||
const { data: jobResult } = useRagJobStatus(activeRequestId, isPolling);
|
||||
|
||||
// หยุด polling เมื่อ job เสร็จ/ล้มเหลว/ยกเลิก
|
||||
useEffect(() => {
|
||||
if (!jobResult) return;
|
||||
if (
|
||||
jobResult.status === 'completed' ||
|
||||
jobResult.status === 'failed' ||
|
||||
jobResult.status === 'cancelled'
|
||||
) {
|
||||
setIsPolling(false);
|
||||
if (jobResult.status === 'failed') {
|
||||
toast.error('การค้นหาล้มเหลว กรุณาลองใหม่อีกครั้ง');
|
||||
}
|
||||
}
|
||||
}, [jobResult]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = question.trim();
|
||||
if (!trimmed || isAiOffline || submitMutation.isPending) return;
|
||||
|
||||
try {
|
||||
const result = await submitMutation.mutateAsync({
|
||||
question: trimmed,
|
||||
projectPublicId,
|
||||
});
|
||||
setActiveRequestId(result.requestPublicId);
|
||||
setIsPolling(true);
|
||||
} catch {
|
||||
toast.error('ไม่สามารถส่งคำถามได้ กรุณาลองใหม่');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!activeRequestId) return;
|
||||
try {
|
||||
await cancelMutation.mutateAsync(activeRequestId);
|
||||
setIsPolling(false);
|
||||
} catch {
|
||||
toast.error('ไม่สามารถยกเลิกได้');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setQuestion('');
|
||||
setActiveRequestId(null);
|
||||
setIsPolling(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
void handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const isActive = submitMutation.isPending || isPolling;
|
||||
const showResult = !!jobResult && (jobResult.status === 'completed' || jobResult.status === 'failed');
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
RAG Q&A — ค้นหาจากเอกสารโครงการ
|
||||
{isAiOffline && (
|
||||
<Badge variant="destructive" className="ml-auto text-xs">
|
||||
AI Offline
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{isAiOffline && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
ระบบ AI ไม่พร้อมใช้งานในขณะนี้ ฟีเจอร์ RAG ถูกปิดใช้งานชั่วคราว (FR-006)
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="พิมพ์คำถามเกี่ยวกับเอกสารโครงการ... (Ctrl+Enter เพื่อส่ง)"
|
||||
className="min-h-[80px] resize-none"
|
||||
maxLength={500}
|
||||
disabled={isAiOffline || isActive}
|
||||
/>
|
||||
<p className="text-right text-xs text-muted-foreground">
|
||||
{question.length}/500
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Job Status Indicator */}
|
||||
{activeRequestId && jobResult && (
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{(jobResult.status === 'pending' || jobResult.status === 'processing') && (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
)}
|
||||
<Badge variant={statusBadge(jobResult.status).variant}>
|
||||
{statusBadge(jobResult.status).label}
|
||||
</Badge>
|
||||
</div>
|
||||
{jobResult.status !== 'completed' && jobResult.status !== 'failed' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void handleCancel()}
|
||||
disabled={cancelMutation.isPending}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
ยกเลิก
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Answer */}
|
||||
{showResult && jobResult.answer && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">คำตอบ:</p>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
|
||||
{jobResult.answer}
|
||||
</p>
|
||||
{typeof jobResult.confidence === 'number' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
ความเชื่อมั่น: {(jobResult.confidence * 100).toFixed(1)}%
|
||||
{jobResult.usedFallbackModel && ' (Fallback Model)'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{showResult && jobResult.status === 'failed' && (
|
||||
<p className="text-sm text-destructive">{jobResult.errorMessage ?? 'เกิดข้อผิดพลาดที่ไม่ทราบสาเหตุ'}</p>
|
||||
)}
|
||||
|
||||
{/* Citations */}
|
||||
{showResult && jobResult.citations && jobResult.citations.length > 0 && (
|
||||
<CitationList citations={jobResult.citations} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex gap-2 pt-0">
|
||||
{!showResult ? (
|
||||
<Button
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={!question.trim() || isAiOffline || isActive}
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
>
|
||||
{isActive ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{isActive ? 'กำลังค้นหา...' : 'ส่งคำถาม'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={handleReset} className="ml-auto">
|
||||
ถามคำถามใหม่
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** แสดง citations จาก RAG results */
|
||||
function CitationList({ citations }: { citations: AiRagCitation[] }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const visible = expanded ? citations : citations.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
เอกสารอ้างอิง ({citations.length} รายการ):
|
||||
</p>
|
||||
{visible.map((c, index) => (
|
||||
<div
|
||||
key={`${String(c.pointId)}-${index}`}
|
||||
className="rounded border border-border bg-muted/40 p-2 text-xs space-y-0.5"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">
|
||||
{c.docType ?? 'เอกสาร'}
|
||||
{c.docNumber ? ` — ${c.docNumber}` : ''}
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{(c.score * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
{c.snippet && (
|
||||
<p className="text-muted-foreground line-clamp-2">{c.snippet}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{citations.length > 3 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1 text-xs w-full"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? 'แสดงน้อยลง' : `ดูอีก ${citations.length - 3} รายการ`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// File: lib/api/ai.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม hooks สำหรับ AI staging queue ตาม ADR-023.
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import apiClient from '@/lib/api/client';
|
||||
|
||||
export enum AiStagingStatus {
|
||||
PENDING = 'PENDING',
|
||||
IMPORTED = 'IMPORTED',
|
||||
REJECTED = 'REJECTED',
|
||||
}
|
||||
|
||||
export interface AiStagingRecord {
|
||||
publicId: string;
|
||||
batchId: string;
|
||||
originalFileName: string;
|
||||
sourceAttachmentPublicId?: string;
|
||||
extractedMetadata?: Record<string, unknown>;
|
||||
confidenceScore?: number;
|
||||
status: AiStagingStatus;
|
||||
errorReason?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AiStagingQueueResponse {
|
||||
items: AiStagingRecord[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface ApproveAiStagingPayload {
|
||||
documentNumber: string;
|
||||
subject: string;
|
||||
categoryCode: string;
|
||||
projectPublicId: string;
|
||||
senderOrganizationPublicId?: string;
|
||||
receiverOrganizationPublicId?: string;
|
||||
issuedDate?: string;
|
||||
receivedDate?: string;
|
||||
body?: string;
|
||||
finalMetadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface WrappedData<T> {
|
||||
data?: T;
|
||||
}
|
||||
|
||||
const extractData = <T>(value: unknown): T => {
|
||||
let current: unknown = value;
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
if (!current || typeof current !== 'object' || !('data' in current)) {
|
||||
return current as T;
|
||||
}
|
||||
current = (current as WrappedData<unknown>).data;
|
||||
}
|
||||
return current as T;
|
||||
};
|
||||
|
||||
export const aiStagingKeys = {
|
||||
all: ['ai-staging'] as const,
|
||||
queue: (status?: AiStagingStatus) =>
|
||||
[...aiStagingKeys.all, 'queue', status ?? 'ALL'] as const,
|
||||
};
|
||||
|
||||
export function useAiStagingQueue(status?: AiStagingStatus) {
|
||||
return useQuery({
|
||||
queryKey: aiStagingKeys.queue(status),
|
||||
queryFn: async (): Promise<AiStagingQueueResponse> => {
|
||||
const response = await apiClient.get('/ai/legacy-migration/queue', {
|
||||
params: { status, page: 1, limit: 50 },
|
||||
});
|
||||
return extractData<AiStagingQueueResponse>(response.data);
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── RAG Query Hooks (Phase 4) ────────────────────────────────────────────────
|
||||
|
||||
export type RagJobStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'not_found';
|
||||
|
||||
export interface AiRagCitation {
|
||||
pointId: string | number;
|
||||
score: number;
|
||||
docType?: string;
|
||||
docNumber?: string;
|
||||
snippet?: string;
|
||||
}
|
||||
|
||||
export interface AiRagJobResult {
|
||||
requestPublicId: string;
|
||||
status: RagJobStatus;
|
||||
answer?: string;
|
||||
citations?: AiRagCitation[];
|
||||
confidence?: number;
|
||||
usedFallbackModel?: boolean;
|
||||
errorMessage?: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface SubmitRagQueryPayload {
|
||||
question: string;
|
||||
projectPublicId: string;
|
||||
}
|
||||
|
||||
export const ragQueryKeys = {
|
||||
all: ['ai-rag'] as const,
|
||||
job: (requestPublicId: string) => [...ragQueryKeys.all, 'job', requestPublicId] as const,
|
||||
};
|
||||
|
||||
export function useSubmitRagQuery() {
|
||||
return useMutation({
|
||||
mutationFn: async (payload: SubmitRagQueryPayload): Promise<{ requestPublicId: string; jobId: string; status: string }> => {
|
||||
const response = await apiClient.post('/ai/rag/query', payload, {
|
||||
headers: { 'Idempotency-Key': `rag-${Date.now()}` },
|
||||
});
|
||||
return extractData<{ requestPublicId: string; jobId: string; status: string }>(response.data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRagJobStatus(requestPublicId: string | null, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ragQueryKeys.job(requestPublicId ?? ''),
|
||||
queryFn: async (): Promise<AiRagJobResult> => {
|
||||
const response = await apiClient.get(`/ai/rag/jobs/${requestPublicId}`);
|
||||
return extractData<AiRagJobResult>(response.data);
|
||||
},
|
||||
enabled: enabled && !!requestPublicId,
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
if (status === 'completed' || status === 'failed' || status === 'cancelled') return false;
|
||||
return 2000;
|
||||
},
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCancelRagJob() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (requestPublicId: string): Promise<void> => {
|
||||
await apiClient.delete(`/ai/rag/jobs/${requestPublicId}`);
|
||||
},
|
||||
onSuccess: (_data, requestPublicId) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ragQueryKeys.job(requestPublicId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useApproveAiStagingRecord() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
publicId,
|
||||
payload,
|
||||
}: {
|
||||
publicId: string;
|
||||
payload: ApproveAiStagingPayload;
|
||||
}) => {
|
||||
const response = await apiClient.post(
|
||||
`/ai/legacy-migration/queue/${publicId}/approve`,
|
||||
payload
|
||||
);
|
||||
return extractData<{ record: AiStagingRecord; importResult: unknown }>(
|
||||
response.data
|
||||
);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: aiStagingKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -74,6 +74,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.57.1",
|
||||
"@typescript-eslint/parser": "^8.57.1",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"baseline-browser-mapping": "^2.10.8",
|
||||
"eslint": "^9.39.1",
|
||||
|
||||
@@ -37,5 +37,38 @@
|
||||
"filePreview.unsupported": "Preview is not available for this file type.",
|
||||
"filePreview.loadError": "Unable to load file. Please try again.",
|
||||
"filePreview.download": "Download",
|
||||
"filePreview.close": "Close"
|
||||
"filePreview.close": "Close",
|
||||
|
||||
"ai.status.offlineTitle": "AI unavailable",
|
||||
"ai.status.offlineDescription": "AI staging is temporarily unavailable. Manual document operations remain available.",
|
||||
"ai.status.onlineTitle": "AI staging available",
|
||||
"ai.status.onlineDescription": "Legacy migration review queue is connected.",
|
||||
"ai.staging.title": "AI Staging Queue",
|
||||
"ai.staging.subtitle": "Review AI-extracted legacy metadata before committing it to DMS records.",
|
||||
"ai.staging.refresh": "Refresh",
|
||||
"ai.staging.file": "File",
|
||||
"ai.staging.batch": "Batch",
|
||||
"ai.staging.confidence": "Confidence",
|
||||
"ai.staging.status": "Status",
|
||||
"ai.staging.review": "Review",
|
||||
"ai.staging.empty": "—",
|
||||
"ai.staging.loading": "Loading staging records...",
|
||||
"ai.staging.emptyQueue": "No staging records found.",
|
||||
"ai.staging.reviewTitle": "Review AI Metadata",
|
||||
"ai.staging.documentNumber": "Document number",
|
||||
"ai.staging.category": "Category",
|
||||
"ai.staging.selectCategory": "Select category",
|
||||
"ai.staging.subject": "Subject",
|
||||
"ai.staging.project": "Project",
|
||||
"ai.staging.selectProject": "Select project",
|
||||
"ai.staging.sender": "Sender",
|
||||
"ai.staging.selectSender": "Select sender",
|
||||
"ai.staging.receiver": "Receiver",
|
||||
"ai.staging.selectReceiver": "Select receiver",
|
||||
"ai.staging.issuedDate": "Issued date",
|
||||
"ai.staging.receivedDate": "Received date",
|
||||
"ai.staging.body": "Body",
|
||||
"ai.staging.approve": "Approve",
|
||||
"ai.staging.approveSuccess": "Staging record approved.",
|
||||
"ai.staging.approveError": "Unable to approve staging record."
|
||||
}
|
||||
|
||||
@@ -37,5 +37,38 @@
|
||||
"filePreview.unsupported": "ไม่รองรับการแสดงผลสำหรับไฟล์ประเภทนี้",
|
||||
"filePreview.loadError": "ไม่สามารถโหลดไฟล์ได้ กรุณาลองใหม่",
|
||||
"filePreview.download": "ดาวน์โหลด",
|
||||
"filePreview.close": "ปิด"
|
||||
"filePreview.close": "ปิด",
|
||||
|
||||
"ai.status.offlineTitle": "ระบบ AI ไม่พร้อมใช้งาน",
|
||||
"ai.status.offlineDescription": "ไม่สามารถเชื่อมต่อ staging queue ของ AI ได้ชั่วคราว แต่ยังทำงานเอกสารแบบ manual ได้ตามปกติ",
|
||||
"ai.status.onlineTitle": "ระบบ AI พร้อมใช้งาน",
|
||||
"ai.status.onlineDescription": "เชื่อมต่อคิวตรวจสอบข้อมูลเอกสารเก่าเรียบร้อยแล้ว",
|
||||
"ai.staging.title": "คิวตรวจสอบ AI",
|
||||
"ai.staging.subtitle": "ตรวจสอบ metadata จาก AI ก่อนบันทึกเข้า DMS",
|
||||
"ai.staging.refresh": "รีเฟรช",
|
||||
"ai.staging.file": "ไฟล์",
|
||||
"ai.staging.batch": "Batch",
|
||||
"ai.staging.confidence": "ความมั่นใจ",
|
||||
"ai.staging.status": "สถานะ",
|
||||
"ai.staging.review": "ตรวจสอบ",
|
||||
"ai.staging.empty": "—",
|
||||
"ai.staging.loading": "กำลังโหลดรายการ...",
|
||||
"ai.staging.emptyQueue": "ยังไม่มีรายการในคิว",
|
||||
"ai.staging.reviewTitle": "ตรวจสอบ Metadata จาก AI",
|
||||
"ai.staging.documentNumber": "เลขที่เอกสาร",
|
||||
"ai.staging.category": "ประเภท",
|
||||
"ai.staging.selectCategory": "เลือกประเภท",
|
||||
"ai.staging.subject": "เรื่อง",
|
||||
"ai.staging.project": "โครงการ",
|
||||
"ai.staging.selectProject": "เลือกโครงการ",
|
||||
"ai.staging.sender": "ผู้ส่ง",
|
||||
"ai.staging.selectSender": "เลือกผู้ส่ง",
|
||||
"ai.staging.receiver": "ผู้รับ",
|
||||
"ai.staging.selectReceiver": "เลือกผู้รับ",
|
||||
"ai.staging.issuedDate": "วันที่ออก",
|
||||
"ai.staging.receivedDate": "วันที่รับ",
|
||||
"ai.staging.body": "เนื้อหา",
|
||||
"ai.staging.approve": "อนุมัติ",
|
||||
"ai.staging.approveSuccess": "อนุมัติรายการเรียบร้อยแล้ว",
|
||||
"ai.staging.approveError": "ไม่สามารถอนุมัติรายการได้"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user