kcg-ai-monitoring/frontend/src/features/ai-operations/AIAssistant.tsx
htlee 8af693a2df refactor(i18n): alert/confirm/aria-label 하드코딩 한글 제거
공통 번역 리소스 확장:
- common.json 에 aria / error / dialog / success / message 네임스페이스 추가
- ko/en 양쪽 동일 구조 유지 (aria 36 키 + error 7 키 + dialog 4 키 + message 5 키)

alert/confirm 11건 → t() 치환:
- parent-inference: ParentReview / LabelSession / ParentExclusion
- admin: PermissionsPanel / UserRoleAssignDialog / AccessControl

aria-label 한글 40+건 → t() 치환:
- parent-inference (group_key/sub_cluster/정답 parent MMSI/스코프 필터 등)
- admin (역할 코드/이름, 알림 제목/내용, 시작일/종료일, 코드 검색, 대분류 필터, 수신 현황 기준일)
- detection (그룹 유형/해역 필터, 관심영역, 필터 설정/초기화, 멤버 수, 미니맵/재생 닫기)
- enforcement (확인/선박 상세/단속 등록/오탐 처리)
- vessel/statistics/ai-operations (조회 시작/종료 시각, 업로드 패널 닫기, 전송, 예시 URL 복사)
- 공통 컴포넌트 (SearchInput, NotificationBanner)

MainLayout 언어 토글:
- title 삼항분기 → t('message.switchToEnglish'/'switchToKorean')
- aria-label="페이지 내 검색" → t('aria.searchInPage')
- 토글 버튼 자체에 aria-label={t('aria.languageToggle')} 추가
2026-04-16 16:32:37 +09:00

161 lines
7.4 KiB
TypeScript

import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { MessageSquare, Send, Bot, User, BookOpen, Shield, AlertTriangle, FileText, ExternalLink } from 'lucide-react';
import { sendChatMessage } from '@/services/chatApi';
/* SFR-20: 자연어 처리 기반 AI 의사결정 지원(Q&A) 서비스 */
interface Message { role: 'user' | 'assistant'; content: string; refs?: string[]; }
const SAMPLE_CONVERSATIONS = [
{ id: '1', title: '서해 NLL 침범 대응 절차', time: '08:45' },
{ id: '2', title: '중국어선 환적 판별 기준', time: '08:20' },
{ id: '3', title: '조업 금지 기간 법령 조회', time: '어제' },
{ id: '4', title: 'MMSI 변조 선박 처리 방안', time: '04-02' },
];
const INITIAL_MESSAGES: Message[] = [
{ role: 'user', content: '서해 NLL 인근에서 중국어선이 EEZ를 침범한 경우 단속 절차는 어떻게 되나요?' },
{ role: 'assistant', content: `EEZ 침범 중국어선에 대한 단속 절차는 다음과 같습니다:
**1단계: 탐지·식별**
- AI 시스템에서 EEZ 침범 자동 탐지 (SFR-09 패턴탐지)
- AIS/레이더를 통한 선박 식별 (MMSI, 선명, 국적)
**2단계: 경보·출동**
- 상황실 즉시 경보 발령 (SFR-17 AI 알림)
- 인근 함정 출동 지시 (SFR-07 경로 추천)
**3단계: 정선·검문**
- VHF 16채널 정선 명령 (한·중·영)
- 임검반 승선 검문 (선박서류, 어획물, 어구)
**4단계: 조치**
- 위반 확인 시: 나포 → 인천/목포 호송 → 담보금 부과
- 경미 위반: 경고 조치 후 퇴거 명령
**관련 법령:**
- 배타적경제수역법 제5조 (외국인 어업 제한)
- 한중어업협정 제6조 (특정수역 관리)`, refs: ['배타적경제수역법 제5조', '한중어업협정 제6조', 'SFR-09 패턴탐지', 'SFR-17 AI알림'] },
];
export function AIAssistant() {
const { t } = useTranslation('ai');
const { t: tc } = useTranslation('common');
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
const [input, setInput] = useState('');
const [selectedConv, setSelectedConv] = useState('1');
const handleSend = async () => {
if (!input.trim()) return;
const userMsg = input;
setMessages((prev) => [...prev, { role: 'user', content: userMsg }]);
setInput('');
// 백엔드 prediction chat 프록시 호출
setMessages((prev) => [...prev, { role: 'assistant', content: '질의를 분석 중입니다...', refs: [] }]);
try {
const res = await sendChatMessage(userMsg);
const reply = res.ok
? (res.reply ?? '응답 없음')
: (res.message ?? 'Prediction 채팅 미연결');
setMessages((prev) => {
const next = [...prev];
next[next.length - 1] = { role: 'assistant', content: reply, refs: [] };
return next;
});
} catch (e) {
setMessages((prev) => {
const next = [...prev];
next[next.length - 1] = { role: 'assistant', content: '에러: ' + (e instanceof Error ? e.message : 'unknown'), refs: [] };
return next;
});
}
};
return (
<PageContainer className="h-full flex flex-col">
<PageHeader
icon={MessageSquare}
iconColor="text-green-400"
title={t('assistant.title')}
description={t('assistant.desc')}
/>
<div className="flex-1 flex gap-3 min-h-0">
{/* 대화 이력 사이드바 */}
<Card className="w-56 shrink-0 bg-surface-raised border-border">
<CardContent className="p-3">
<div className="text-[11px] font-bold text-label mb-2"> </div>
<div className="space-y-1">
{SAMPLE_CONVERSATIONS.map(c => (
<div key={c.id} onClick={() => setSelectedConv(c.id)}
className={`px-2 py-1.5 rounded-lg cursor-pointer text-[10px] ${selectedConv === c.id ? 'bg-green-600/15 text-green-400 border border-green-500/20' : 'text-muted-foreground hover:bg-surface-overlay'}`}>
<div className="truncate">{c.title}</div>
<div className="text-[8px] text-hint mt-0.5">{c.time}</div>
</div>
))}
</div>
<div className="mt-3 pt-2 border-t border-border">
<div className="text-[9px] text-hint flex items-center gap-1"><Shield className="w-3 h-3" /> </div>
<div className="text-[9px] text-hint flex items-center gap-1 mt-1"><BookOpen className="w-3 h-3" /> DB 2,345 </div>
</div>
</CardContent>
</Card>
{/* 채팅 영역 */}
<div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 overflow-y-auto space-y-3 mb-3">
{messages.map((msg, i) => (
<div key={i} className={`flex gap-2 ${msg.role === 'user' ? 'justify-end' : ''}`}>
{msg.role === 'assistant' && (
<div className="w-7 h-7 rounded-full bg-green-600/20 border border-green-500/30 flex items-center justify-center shrink-0">
<Bot className="w-4 h-4 text-green-400" />
</div>
)}
<div className={`max-w-[70%] rounded-xl px-4 py-3 ${
msg.role === 'user'
? 'bg-blue-600/20 border border-blue-500/20'
: 'bg-surface-overlay border border-border'
}`}>
<div className="text-[11px] text-foreground whitespace-pre-wrap leading-relaxed">{msg.content}</div>
{msg.refs && msg.refs.length > 0 && (
<div className="mt-2 pt-2 border-t border-border flex flex-wrap gap-1">
{msg.refs.map(r => (
<Badge key={r} className="bg-green-500/10 text-green-400 border-0 text-[8px] flex items-center gap-0.5">
<FileText className="w-2.5 h-2.5" />{r}
</Badge>
))}
</div>
)}
</div>
{msg.role === 'user' && (
<div className="w-7 h-7 rounded-full bg-blue-600/20 border border-blue-500/30 flex items-center justify-center shrink-0">
<User className="w-4 h-4 text-blue-400" />
</div>
)}
</div>
))}
</div>
{/* 입력창 */}
<div className="flex gap-2 shrink-0">
<input
aria-label="AI 어시스턴트 질의"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSend()}
placeholder="질의를 입력하세요... (법령, 단속 절차, AI 분석 결과 등)"
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded-xl px-4 py-2.5 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-green-500/50"
/>
<button type="button" aria-label={tc('aria.send')} onClick={handleSend} className="px-4 py-2.5 bg-green-600 hover:bg-green-500 text-on-vivid rounded-xl transition-colors">
<Send className="w-4 h-4" />
</button>
</div>
</div>
</div>
</PageContainer>
);
}